teqo-backup/tests/unit/archiveIntegrityRepair.unit.spec.ts
Francisco Solla 255751f5ce fix(archive): teto de 15 min no download do reparo e caminhos do runbook (C246-followup)
O canário 80/83 abortou no download do Flickr: original de ~54 MB (panorâmica 7952px) a ~260 KB/s no link direto do homeserver leva ~3,5 min, acima do teto de 2 min herdado do C231. ARCHIVE_INTEGRITY_DOWNLOAD_TIMEOUT_MS vira 15 min (exportada e pinada no unit); falha de download segue failed retentável. Runbook §C246 usa ~/archive-reports (o /srv do texto original não existe/grava) e ganha docker pull do migrator (a limpeza pós-deploy remove a tag local).
2026-10-02 17:37:39 -03:00

238 lines
7.9 KiB
TypeScript

// @vitest-environment node
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { afterAll, describe, expect, it, vi } from 'vitest'
import {
ARCHIVE_INTEGRITY_DOWNLOAD_TIMEOUT_MS,
repairArchivePhotoFromSource,
} from '../../scripts/lib/archiveIntegrityRepair.mjs'
import { FlickrApiError } from '../../scripts/lib/flickrApi.mjs'
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
// C246 — the per-photo repair decision with every side effect faked: a gone
// source or an undecodable download leaves the public (withdraw); a transient
// source/download failure is retryable; a write/verification failure is never
// reported as recovered.
const tempDirs: string[] = []
type Deps = {
row: { id: number; flickrId: string; filename: string | null; publicationStatus: string }
tempRoot: string
staticDir: string
payload: unknown
client: { getLargestSize: (flickrId: string) => Promise<{ url: string } | null> }
download: (args: {
url: string
destinationPath: string
headers: Record<string, string>
timeoutMs: number
maxBytes: number
}) => Promise<void>
repair: (
payload: unknown,
args: { id: number; filePath: string },
) => Promise<{ status: string; filename?: string | null; error?: string }>
inspect: (args: {
media: { filename: string | null }
staticDir: string
destinationPath: string
}) => Promise<{ status: string; stage?: string; reason?: string; bytes?: number }>
withdraw: (entry: {
id: number
flickrId: string
filename: string | null
reason: string
}) => Promise<Record<string, unknown>>
}
const makeTempRoot = async (): Promise<string> => {
const dir = await mkdtemp(join(tmpdir(), 'c246-repair-'))
tempDirs.push(dir)
return dir
}
const makeDeps = async (
overrides: Partial<Deps> = {},
): Promise<{ deps: Deps; callOrder: string[] }> => {
const callOrder: string[] = []
const deps: Deps = {
row: { id: 80, flickrId: '123', filename: 'flickr-123.jpg', publicationStatus: 'approved' },
tempRoot: await makeTempRoot(),
staticDir: '/nao-usado',
payload: {},
client: {
getLargestSize: vi.fn(async () => {
callOrder.push('source')
return { url: 'https://live.staticflickr.com/65535/123_o.jpg' }
}),
},
download: vi.fn(async ({ destinationPath }: { destinationPath: string }) => {
callOrder.push('download')
await writeFile(destinationPath, ARCHIVE_PHOTO_JPEG_BYTES)
}),
repair: vi.fn(async () => {
callOrder.push('repair')
return { status: 'repaired', id: 80, filename: 'flickr-123.jpg', filesize: 268 }
}),
inspect: vi.fn(async () => {
callOrder.push('inspect')
return { status: 'ok', bytes: ARCHIVE_PHOTO_JPEG_BYTES.length }
}),
withdraw: vi.fn(async (entry: Record<string, unknown>) => {
callOrder.push('withdraw')
return { ...entry, action: 'unrecoverable', previousStatus: 'approved', newStatus: 'draft' }
}),
...overrides,
}
return { deps, callOrder }
}
const repair = (deps: Deps) => repairArchivePhotoFromSource(deps)
afterAll(async () => {
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
})
describe('repairArchivePhotoFromSource (C246)', () => {
it('recovers: source → download → decode → repair → verify, with provenance', async () => {
const { deps, callOrder } = await makeDeps()
const result = await repair(deps)
expect(result).toMatchObject({
id: 80,
flickrId: '123',
filename: 'flickr-123.jpg',
action: 'recovered',
sourceUrl: 'https://live.staticflickr.com/65535/123_o.jpg',
bytes: ARCHIVE_PHOTO_JPEG_BYTES.length,
})
expect(result.sha256).toMatch(/^[0-9a-f]{64}$/)
expect(callOrder).toEqual(['source', 'download', 'repair', 'inspect'])
expect(basename(vi.mocked(deps.repair).mock.calls[0][1].filePath)).toBe('flickr-123.jpg')
// A >50MB Flickr original does not fit the old 2 min ceiling on a slow link.
expect(vi.mocked(deps.download).mock.calls[0][0].timeoutMs).toBe(
ARCHIVE_INTEGRITY_DOWNLOAD_TIMEOUT_MS,
)
})
it('withdraws when Flickr refuses the source (photo gone)', async () => {
const client = {
getLargestSize: vi.fn(async () => {
throw new FlickrApiError('Flickr 1: Photo not found', { code: 1 })
}),
}
const { deps } = await makeDeps({ client })
const result = await repair(deps)
expect(result).toMatchObject({ action: 'unrecoverable', newStatus: 'draft' })
expect(result.reason).toContain('recusou a fonte')
expect(deps.repair).not.toHaveBeenCalled()
expect(deps.withdraw).toHaveBeenCalledTimes(1)
})
it('withdraws when the source has no usable size', async () => {
const client = { getLargestSize: vi.fn(async () => null) }
const { deps } = await makeDeps({ client })
const result = await repair(deps)
expect(result).toMatchObject({ action: 'unrecoverable' })
expect(result.reason).toContain('nenhum tamanho utilizável')
})
it('keeps a transient source failure as a retryable failure', async () => {
const client = {
getLargestSize: vi.fn(async () => {
throw new Error('getaddrinfo ENOTFOUND')
}),
}
const { deps } = await makeDeps({ client })
const result = await repair(deps)
expect(result).toMatchObject({ action: 'failed', stage: 'source' })
expect(deps.withdraw).not.toHaveBeenCalled()
})
it('keeps a download failure as retryable and never touches the object', async () => {
const download = vi.fn(async () => {
throw new Error('Download falhou (HTTP 500).')
})
const { deps } = await makeDeps({ download })
const result = await repair(deps)
expect(result).toMatchObject({ action: 'failed', stage: 'download' })
expect(deps.repair).not.toHaveBeenCalled()
expect(deps.withdraw).not.toHaveBeenCalled()
})
it('withdraws when the downloaded source does not decode', async () => {
const download = vi.fn(async ({ destinationPath }: { destinationPath: string }) => {
await writeFile(destinationPath, Buffer.from('nao e imagem'))
})
const { deps } = await makeDeps({ download })
const result = await repair(deps)
expect(result).toMatchObject({ action: 'unrecoverable' })
expect(result.reason).toContain('não decodifica')
expect(deps.repair).not.toHaveBeenCalled()
})
it('reports a failed object write without claiming recovery', async () => {
const repairMock = vi.fn(async () => ({ status: 'failed', id: 80, error: 'boom' }))
const { deps } = await makeDeps({ repair: repairMock })
const result = await repair(deps)
expect(result).toMatchObject({ action: 'failed', stage: 'repair', reason: 'boom' })
expect(deps.inspect).not.toHaveBeenCalled()
})
it('reports a failed verification of the repaired object', async () => {
const inspect = vi.fn(async () => ({
status: 'corrupt',
stage: 'decode',
reason: 'ainda quebrado',
bytes: 1,
}))
const { deps } = await makeDeps({ inspect })
const result = await repair(deps)
expect(result).toMatchObject({
action: 'failed',
stage: 'verify',
reason: 'decode: ainda quebrado',
})
})
it('derives the deterministic name when the row has no stored filename', async () => {
const repairMock = vi.fn(async (_payload: unknown, { filePath }: { filePath: string }) => ({
status: 'repaired',
id: 81,
filename: basename(filePath),
filesize: 0,
}))
const { deps } = await makeDeps({
row: { id: 81, flickrId: '456', filename: null, publicationStatus: 'draft' },
repair: repairMock,
})
const result = await repair(deps)
expect(result).toMatchObject({
action: 'recovered',
filename: 'flickr-456.jpg',
})
expect(basename(vi.mocked(deps.repair).mock.calls[0][1].filePath)).toBe('flickr-456.jpg')
})
})