mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
Adiciona a CLI pnpm archive:integrity: varredura read-only de todas as archivePhoto (download + decode pelo mesmo caminho da rota pública e do índice facial; sai 1 enquanto houver mídia quebrada approved) e --apply que re-ingere do Flickr pelo flickrId, substitui o objeto na mesma chave via payload.update filePath (sem tocar curadoria/status) e rebaixa a draft as irrecuperáveis (removed intocado). Dono da leitura ganha inspectPrivateMediaObject e isPrivateMediaMissingError (S3 NoSuchKey/404 + ENOENT local; checksum nunca é missing); archivePhotoIngest ganha repairArchivePhotoObject e withdrawArchivePhotoFromPublic; decisão do reparo em scripts/lib/archiveIntegrityRepair.mjs com deps injetadas e puro de CLI/recibo em scripts/lib/archiveIntegrityPlan.mjs. Sem migration, sem UI. Guardas: ARCHIVE_INTEGRITY_CONFIRM + TEQO_ENV exato + 4 S3_* (em qualquer modo no alvo não-local) + FLICKR_API_KEY. Runbook C246, changelog e unit/int/e2e afetado.
207 lines
7.3 KiB
TypeScript
207 lines
7.3 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
FLICKR_PHOTO_EXTRAS,
|
|
FlickrApiError,
|
|
createFlickrClient,
|
|
isFlickrSourceGone,
|
|
} from '../../scripts/lib/flickrApi.mjs'
|
|
|
|
// C231 — the Flickr client contract: request shape, pacing, transient retry,
|
|
// named `stat=fail` refusals, the exif tolerance and the largest-size
|
|
// fallback. `fetch` is always a fake — the real API never enters the suite.
|
|
|
|
const jsonResponse = (body: unknown, status = 200) =>
|
|
({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: async () => body,
|
|
}) as Response
|
|
|
|
const fakeFetch = (impl: (url: string, init?: RequestInit) => Promise<Response>) =>
|
|
vi.fn(impl) as unknown as typeof fetch
|
|
|
|
const clientWith = (
|
|
fetchImpl: typeof fetch,
|
|
overrides: Record<string, unknown> = {},
|
|
): ReturnType<typeof createFlickrClient> =>
|
|
createFlickrClient({
|
|
apiKey: 'test-key',
|
|
fetchImpl,
|
|
pacingMs: 0,
|
|
retryAttempts: 1,
|
|
...overrides,
|
|
})
|
|
|
|
describe('createFlickrClient (C231)', () => {
|
|
it('refuses to be created without the API key', () => {
|
|
expect(() => createFlickrClient({ apiKey: ' ' })).toThrow(/FLICKR_API_KEY/)
|
|
expect(() => createFlickrClient()).toThrow(/FLICKR_API_KEY/)
|
|
})
|
|
|
|
it('sends the method, key, JSON contract and extras; parses the listing', async () => {
|
|
const fetchImpl = fakeFetch(async () =>
|
|
jsonResponse({
|
|
stat: 'ok',
|
|
photos: { page: 1, pages: 2, total: 7000, photo: [{ id: '1' }] },
|
|
}),
|
|
)
|
|
const client = clientWith(fetchImpl)
|
|
|
|
const photos = await client.listPhotosPage({ userId: '123@N00', page: 2 })
|
|
|
|
const called = new URL(
|
|
String((fetchImpl as unknown as { mock: { calls: string[][] } }).mock.calls[0][0]),
|
|
)
|
|
expect(called.origin + called.pathname).toBe('https://api.flickr.com/services/rest/')
|
|
expect(called.searchParams.get('method')).toBe('flickr.people.getPhotos')
|
|
expect(called.searchParams.get('api_key')).toBe('test-key')
|
|
expect(called.searchParams.get('nojsoncallback')).toBe('1')
|
|
expect(called.searchParams.get('user_id')).toBe('123@N00')
|
|
expect(called.searchParams.get('page')).toBe('2')
|
|
expect(called.searchParams.get('extras')).toBe(FLICKR_PHOTO_EXTRAS)
|
|
expect(photos).toMatchObject({ page: 1, pages: 2, total: 7000 })
|
|
})
|
|
|
|
it('paces sequential calls with the configured interval', async () => {
|
|
const sleeps: number[] = []
|
|
let clock = 0
|
|
const fetchImpl = fakeFetch(async () =>
|
|
jsonResponse({ stat: 'ok', photos: { page: 1, pages: 1, total: 0, photo: [] } }),
|
|
)
|
|
const client = createFlickrClient({
|
|
apiKey: 'k',
|
|
fetchImpl,
|
|
pacingMs: 1000,
|
|
retryAttempts: 1,
|
|
sleep: async (ms: number) => {
|
|
sleeps.push(ms)
|
|
clock += ms
|
|
},
|
|
now: () => clock,
|
|
})
|
|
|
|
await client.call('a')
|
|
clock += 200
|
|
await client.call('b')
|
|
|
|
expect(sleeps).toEqual([800])
|
|
})
|
|
|
|
it('retries HTTP 429/5xx and thrown network errors with growing backoff', async () => {
|
|
const sleeps: number[] = []
|
|
const serverError = fakeFetch(async () => jsonResponse({}, 500))
|
|
const failing = clientWith(serverError, {
|
|
retryAttempts: 3,
|
|
retryBaseDelayMs: 100,
|
|
sleep: async (ms: number) => {
|
|
sleeps.push(ms)
|
|
},
|
|
})
|
|
|
|
await expect(failing.call('x')).rejects.toMatchObject({
|
|
name: 'FlickrApiError',
|
|
status: 500,
|
|
})
|
|
expect(serverError).toHaveBeenCalledTimes(3)
|
|
expect(sleeps).toEqual([100, 200])
|
|
|
|
const rateLimited = fakeFetch(async () => jsonResponse({}, 429))
|
|
await expect(clientWith(rateLimited, { retryAttempts: 2 }).call('x')).rejects.toMatchObject({
|
|
status: 429,
|
|
})
|
|
expect(rateLimited).toHaveBeenCalledTimes(2)
|
|
|
|
const networkError = fakeFetch(async () => {
|
|
throw new Error('getaddrinfo ENOTFOUND')
|
|
})
|
|
await expect(clientWith(networkError, { retryAttempts: 2 }).call('x')).rejects.toThrow(
|
|
'ENOTFOUND',
|
|
)
|
|
expect(networkError).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('never retries a named stat=fail refusal', async () => {
|
|
const fetchImpl = fakeFetch(async () =>
|
|
jsonResponse({ stat: 'fail', code: 100, message: 'Invalid API Key' }),
|
|
)
|
|
const client = clientWith(fetchImpl, { retryAttempts: 4 })
|
|
|
|
await expect(client.call('x')).rejects.toThrow('Flickr 100: Invalid API Key')
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('tolerates an exif refusal but propagates transport failures', async () => {
|
|
const exifFail = fakeFetch(async () =>
|
|
jsonResponse({ stat: 'fail', code: 2, message: 'Photo not found' }),
|
|
)
|
|
expect(await clientWith(exifFail).getExif('1')).toEqual([])
|
|
|
|
const exifBody = fakeFetch(async () =>
|
|
jsonResponse({ stat: 'ok', photo: { id: '1', exif: [{ tag: 'Make', raw: 'Canon' }] } }),
|
|
)
|
|
expect(await clientWith(exifBody).getExif('1')).toEqual([{ tag: 'Make', raw: 'Canon' }])
|
|
|
|
const httpError = fakeFetch(async () => jsonResponse({}, 503))
|
|
await expect(clientWith(httpError).getExif('1')).rejects.toMatchObject({ status: 503 })
|
|
})
|
|
|
|
it('picks the largest image size, ignoring videos and sizes without dimensions', async () => {
|
|
const fetchImpl = fakeFetch(async () =>
|
|
jsonResponse({
|
|
stat: 'ok',
|
|
sizes: {
|
|
size: [
|
|
{ label: 'Small', media: 'photo', width: 240, height: 160, source: 'small' },
|
|
{ label: 'Original', media: 'photo', width: 4000, height: 3000, source: 'original' },
|
|
{ label: 'Sem dims', media: 'photo', source: 'no-dims' },
|
|
{ label: 'Vídeo', media: 'video', width: 9999, height: 9999, source: 'video' },
|
|
],
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(await clientWith(fetchImpl).getLargestSize('1')).toEqual({ url: 'original' })
|
|
|
|
const noSizes = fakeFetch(async () => jsonResponse({ stat: 'ok', sizes: { size: [] } }))
|
|
expect(await clientWith(noSizes).getLargestSize('1')).toBeNull()
|
|
|
|
const onlyUndimensioned = fakeFetch(async () =>
|
|
jsonResponse({
|
|
stat: 'ok',
|
|
sizes: { size: [{ media: 'photo', source: 'no-dims' }] },
|
|
}),
|
|
)
|
|
expect(await clientWith(onlyUndimensioned).getLargestSize('1')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('isFlickrSourceGone (C246)', () => {
|
|
it('treats a photo-not-found refusal and an HTTP 404 as a gone source', () => {
|
|
expect(isFlickrSourceGone(new FlickrApiError('Flickr 1: Photo not found', { code: 1 }))).toBe(
|
|
true,
|
|
)
|
|
expect(isFlickrSourceGone(new FlickrApiError('Flickr 2: Photo not found', { code: 2 }))).toBe(
|
|
true,
|
|
)
|
|
expect(isFlickrSourceGone(new FlickrApiError('HTTP 404', { status: 404 }))).toBe(true)
|
|
})
|
|
|
|
it('never withdraws a photo for auth, outage, transient or unrelated errors', () => {
|
|
expect(
|
|
isFlickrSourceGone(new FlickrApiError('Flickr 100: Invalid API Key', { code: 100 })),
|
|
).toBe(false)
|
|
expect(
|
|
isFlickrSourceGone(
|
|
new FlickrApiError('Flickr 105: Service currently unavailable', { code: 105 }),
|
|
),
|
|
).toBe(false)
|
|
expect(isFlickrSourceGone(new FlickrApiError('HTTP 403', { status: 403 }))).toBe(false)
|
|
expect(isFlickrSourceGone(new FlickrApiError('HTTP 429', { status: 429 }))).toBe(false)
|
|
expect(isFlickrSourceGone(new FlickrApiError('HTTP 500', { status: 500 }))).toBe(false)
|
|
expect(isFlickrSourceGone(new Error('getaddrinfo ENOTFOUND'))).toBe(false)
|
|
expect(isFlickrSourceGone(null)).toBe(false)
|
|
})
|
|
})
|