diff --git a/docs/changelog/2026-09-29-c231-exif.md b/docs/changelog/2026-09-29-c231-exif.md new file mode 100644 index 00000000..51be17df --- /dev/null +++ b/docs/changelog/2026-09-29-c231-exif.md @@ -0,0 +1 @@ +**EXIF do acervo do Flickr (2026-09-29):** o aceite do C231 pedia EXIF "quando houver" e a API do Flickr responde `getExif` com `raw`/`clean` embrulhados em `{ _content: "…" }` (herança XML) — o mapper só aceitava string e descartou todo mundo: as 6.492 fotos foram ingeridas com `exif: []`. `archivePhotoExifEntries` (`src/lib/archivePhoto.ts`) agora desembrulha os dois shapes (o helper `contentScalar` também substituiu o desembrulho ad-hoc do `description`, que já aceitava `_content`), e o CLI ganhou o modo `--refresh-metadata` — o backfill antes deferido: re-busca o EXIF das linhas existentes via API (pacing ~1 req/s), atualiza só o que mudou (`updateArchivePhotoExif`, write-only no campo `exif`, bypass de access documentado do CLI) e grava recibo com atualizadas/inalteradas/falhas nomeadas. Guardas: `--refresh-metadata` é mutuamente exclusivo com `--apply`/`--verify`, exige `FLICKR_IMPORT_CONFIRM=1` e o `TEQO_ENV` casando o banco exato fora de teste — mas **não** exige S3 (não sobe mídia). O geotag segue vazio porque a conta de fato não tem geo (`flickr.photos.search&has_geo=1` → total 0), não por bug. A operação de backfill em produção roda pelo runbook (`docs/ops/teqo-1313-deploy.md`, §C231) e o `--verify` passa a mostrar a cobertura de EXIF. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index f285eb0f..1ecbbdb0 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -1363,13 +1363,36 @@ docker compose --profile maintenance run --rm \ ``` Os recibos JSON ficam em `data/flickr/reports/` do container — monte um volume -(`-v /srv/flickr-reports:/app/data/flickr`) para preservá-los. Guardas: fora de +(`-v $HOME/flickr-reports:/app/data/flickr`) para preservá-los. Guardas: fora de teste o `TEQO_ENV=staging|production` é obrigatório com o **nome exato** do banco (`teqo_staging`/`teqo_1313`); `ALLOW_REMOTE_DB` é recusado; sem as 4 `S3_*` o `--apply` recusa (o container gravaria em disco efêmero); e `FLICKR_IMPORT_CONFIRM=1` é exigido em alvo não-local. A chave da API nunca entra em recibo/log. +### Backfill de EXIF (`--refresh-metadata`) + +Fotos ingeridas antes da correção do mapper gravaram `exif: []` (a API devolve +`raw`/`clean` como `{ _content }`). O modo re-busca o EXIF das linhas +existentes, atualiza só o que mudou e escreve apenas no banco — exige +`FLICKR_API_KEY` e as guardas de escrita, mas **não** exige S3: + +```bash +# no homeserver (canary e depois o lote completo): +docker compose --profile maintenance run --rm \ + -v $HOME/flickr-reports:/app/data/flickr \ + -e TEQO_ENV=production -e FLICKR_IMPORT_CONFIRM=1 \ + teqo-1313-migrate pnpm flickr:import --refresh-metadata --limit 5 +docker compose --profile maintenance run --rm \ + -v $HOME/flickr-reports:/app/data/flickr \ + -e TEQO_ENV=production -e FLICKR_IMPORT_CONFIRM=1 \ + teqo-1313-migrate pnpm flickr:import --refresh-metadata +``` + +O recibo (`flickr-*-refresh.json`) conta atualizadas/inalteradas/falhas com +motivo; o `--verify` mostra a cobertura (`EXIF: N`). O modo é idempotente: +reexecutar converge e linhas inalteradas não são reescritas. + Rollback: a migration `add_archive_photo` é aditiva. O rollback funcional é apagar as linhas do `archivePhoto` no admin (o objeto correspondente no bucket pode ser removido à parte); reexecutar a ingestão recria as mesmas chaves de diff --git a/scripts/ingest-flickr-archive.mjs b/scripts/ingest-flickr-archive.mjs index d4768db6..6c5c0bd5 100644 --- a/scripts/ingest-flickr-archive.mjs +++ b/scripts/ingest-flickr-archive.mjs @@ -14,17 +14,24 @@ * dev and the complete S3_* envs * pnpm flickr:import --verify read-only inventory of the stored * archive (no Flickr, no bucket) + * pnpm flickr:import --refresh-metadata + * re-fetches the EXIF of the rows already + * in the archive and updates only what + * changed (backfill; DB-only write, + * needs FLICKR_API_KEY but no S3) * * Options: --limit (canary), --page (listing start page), --out * (receipts, default `data/flickr`), --help. `--limit` caps the photos * processed; the album walk still covers every album so a canary never loses - * memberships. + * memberships. In the refresh mode `--limit` caps the rows visited. * - * Guards: --apply refuses a non-local/production target without the intent - * flag (C155) and demands the declared TEQO_ENV matching the exact database - * name (C195/C231); a remote target also demands the complete S3_* set. The - * API key lives in FLICKR_API_KEY (env only, never the repo) and the account - * NSID in FLICKR_USER_ID. + * Guards: --apply and --refresh-metadata refuse a non-local/production target + * without the intent flag (C155) and demand the declared TEQO_ENV matching the + * exact database name (C195/C231); --apply on a remote target also demands the + * complete S3_* set (the original never lands on ephemeral disk) while the + * metadata-only refresh writes just the database. The API key lives in + * FLICKR_API_KEY (env only, never the repo) and the account NSID in + * FLICKR_USER_ID. * * Runbook: on the homeserver, inside the compose maintenance service with the * stack env file — see docs/ops/teqo-1313-deploy.md. @@ -36,6 +43,7 @@ import { dirname, join } from 'node:path' import { getPayload } from 'payload' import { + archivePhotoExifEntries, archivePhotoImportFromFlickr, archivePhotoStorageFilename, } from '../src/lib/archivePhoto.ts' @@ -57,11 +65,13 @@ import { collectArchiveListing, formatArchiveBytes, formatArchiveInventory, + formatArchiveRefresh, formatArchiveReport, parseArchiveCliArgs, planArchiveEntries, summarizeArchiveInventory, summarizeArchivePlan, + summarizeArchiveRefresh, summarizeArchiveResults, } from './lib/flickrPlan.mjs' @@ -90,21 +100,24 @@ Modos (mutuamente exclusivos; o default é o plano): (sem flag) plano/dry-run — lista, mapeia e reporta; sem download nem escrita --apply baixa os originais e cria as linhas/objetos no alvo --verify inventário read-only do que já está no acervo (sem Flickr) + --refresh-metadata re-busca o EXIF das linhas existentes e atualiza o que mudou + (backfill; escreve só no banco, exige FLICKR_API_KEY) Opções: - --limit processa no máximo n fotos (a varredura de álbuns continua completa) - --page começa a listagem na página n (retomada) + --limit processa no máximo n fotos; no refresh, no máximo n linhas + --page começa a listagem na página n (retomada; não vale no refresh) --out diretório dos recibos (default ${ARCHIVE_PHOTO_DEFAULT_OUT_DIR}) --help esta ajuda Ambiente: FLICKR_API_KEY chave da API do Flickr (nunca no repo) - FLICKR_USER_ID NSID da conta (ex.: 12345678@N00) + FLICKR_USER_ID NSID da conta (ex.: 12345678@N00; não exigido no --refresh-metadata) DATABASE_URL alvo (obrigatório em todos os modos) ---apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1, TEQO_ENV=${envLines} -casando o banco exato e as quatro envs S3_* (o original nunca vai para disco -efêmero). O dry-run e o --verify são read-only. +--apply e --refresh-metadata em alvo não-local/produção exigem ${WRITE_CONFIRM_FLAG}=1 +e TEQO_ENV=${envLines} casando o banco exato; o --apply exige também as quatro +envs S3_* (o original nunca vai para disco efêmero). O dry-run e o --verify são +read-only. ` class StageError extends Error { @@ -238,39 +251,49 @@ async function main() { } if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.') - const mode = options.apply ? 'apply' : options.verify ? 'verify' : 'plan' + const mode = options.apply + ? 'apply' + : options.verify + ? 'verify' + : options.refreshMetadata + ? 'refresh-metadata' + : 'plan' const runAt = new Date().toISOString() console.log(`[flickr:import] alvo: ${databaseTarget()} | modo: ${mode}`) - if (mode === 'apply') { + if (mode === 'apply' || mode === 'refresh-metadata') { assertWriteConfirm({ label: 'flickr:import', flag: WRITE_CONFIRM_FLAG, - command: 'pnpm flickr:import --apply', + command: `pnpm flickr:import --${mode === 'apply' ? 'apply' : 'refresh-metadata'}`, }) const target = assertEnvironmentDatabaseTarget() - const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts') - const storage = resolveS3StorageEnv(process.env) - if (mirroredMediaRequired({ s3Enabled: storage.enabled })) { - die( - `escrita em produção/remoto (${target.environment}) exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas o original iria para disco local efêmero.`, - ) + if (mode === 'apply') { + const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts') + const storage = resolveS3StorageEnv(process.env) + if (mirroredMediaRequired({ s3Enabled: storage.enabled })) { + die( + `escrita em produção/remoto (${target.environment}) exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas o original iria para disco local efêmero.`, + ) + } } } - if (mode !== 'verify') { - if (!process.env.FLICKR_API_KEY?.trim()) { - die('FLICKR_API_KEY ausente — configure a chave da API do Flickr (veja .env.example).') - } - if (!process.env.FLICKR_USER_ID?.trim()) { - die('FLICKR_USER_ID ausente — informe o NSID da conta (ex.: 12345678@N00).') - } + if (mode !== 'verify' && !process.env.FLICKR_API_KEY?.trim()) { + die('FLICKR_API_KEY ausente — configure a chave da API do Flickr (veja .env.example).') + } + if ((mode === 'plan' || mode === 'apply') && !process.env.FLICKR_USER_ID?.trim()) { + die('FLICKR_USER_ID ausente — informe o NSID da conta (ex.: 12345678@N00).') } const config = (await import('../src/payload.config.ts')).default const { downloadUrlToFile } = await import('../src/utilities/media/downloadToFile.ts') - const { findArchivePhotoByFlickrId, ingestArchivePhoto, listArchivePhotos } = - await import('../src/utilities/flickr/archivePhotoIngest.ts') + const { + findArchivePhotoByFlickrId, + ingestArchivePhoto, + listArchivePhotos, + updateArchivePhotoExif, + } = await import('../src/utilities/flickr/archivePhotoIngest.ts') const payload = await getPayload({ config }) const startedAt = Date.now() @@ -295,6 +318,57 @@ async function main() { process.exit(0) } + if (mode === 'refresh-metadata') { + const client = createFlickrClient({ apiKey: process.env.FLICKR_API_KEY }) + const rows = await listArchivePhotos(payload) + const selected = options.limit === null ? rows : rows.slice(0, options.limit) + console.log( + `[flickr:import] refresh de metadados (EXIF) de ${selected.length} linha(s) do acervo...`, + ) + const results = [] + for (const row of selected) { + const flickrId = String(row.flickrId) + try { + const rawExif = await withStage('metadata', () => client.getExif(flickrId)) + const exif = archivePhotoExifEntries(rawExif) + const current = Array.isArray(row.exif) ? row.exif : [] + if (JSON.stringify(current) === JSON.stringify(exif)) { + results.push({ flickrId, status: 'unchanged', entries: exif.length }) + continue + } + const updated = await updateArchivePhotoExif(payload, { id: row.id, flickrId, exif }) + if (updated.status === 'failed') { + results.push({ flickrId, status: 'failed', error: updated.error }) + console.log(`[flickr:import] falha refresh ${flickrId}: ${updated.error}`) + continue + } + results.push({ flickrId, status: 'updated', entries: exif.length }) + console.log(`[flickr:import] atualizada ${flickrId} (${exif.length} entrada(s) EXIF)`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + results.push({ flickrId, status: 'failed', error: message }) + console.log(`[flickr:import] falha metadata ${flickrId}: ${message}`) + } + } + + const summary = summarizeArchiveRefresh(results) + const report = { + runAt, + mode, + target: databaseTarget(), + total: selected.length, + summary, + durationMs: Date.now() - startedAt, + } + const reportPath = await writeReport(options, report, '-refresh') + for (const line of formatArchiveRefresh(report)) console.log(line) + console.log(`\n[flickr:import] recibo JSON: ${reportPath}`) + if (summary.updated + summary.unchanged === 0 && results.length > 0) { + die('nenhuma linha foi refrescada — falhas listadas no recibo.') + } + process.exit(0) + } + const userId = process.env.FLICKR_USER_ID.trim() const client = createFlickrClient({ apiKey: process.env.FLICKR_API_KEY }) diff --git a/scripts/lib/flickrPlan.mjs b/scripts/lib/flickrPlan.mjs index 0b1a4632..f2f584f9 100644 --- a/scripts/lib/flickrPlan.mjs +++ b/scripts/lib/flickrPlan.mjs @@ -31,7 +31,7 @@ const VIDEO_MEDIA = 'video' const BYTES_PER_MIB = 1024 * 1024 const USAGE = - 'uso: `pnpm flickr:import [--apply|--verify] [--limit ] [--page ] [--out ]`' + 'uso: `pnpm flickr:import [--apply|--verify|--refresh-metadata] [--limit ] [--page ] [--out ]`' export const ARCHIVE_PHOTO_DEFAULT_OUT_DIR = 'data/flickr' @@ -43,12 +43,13 @@ export const archivePhotoReportStamp = (runAt) => runAt.replace(/[:.]/g, '-') * errors carry the usage line. * * @param {string[]} [argv] - * @returns {{ apply: boolean, verify: boolean, limit: number | null, page: number, out: string, help: boolean }} + * @returns {{ apply: boolean, verify: boolean, refreshMetadata: boolean, limit: number | null, page: number, out: string, help: boolean }} */ export const parseArchiveCliArgs = (argv = process.argv.slice(2)) => { const options = { apply: false, verify: false, + refreshMetadata: false, limit: null, page: 1, out: ARCHIVE_PHOTO_DEFAULT_OUT_DIR, @@ -67,6 +68,7 @@ export const parseArchiveCliArgs = (argv = process.argv.slice(2)) => { if (arg === '--help' || arg === '-h') options.help = true else if (arg === '--apply') options.apply = true else if (arg === '--verify') options.verify = true + else if (arg === '--refresh-metadata') options.refreshMetadata = true else if (arg === '--limit') options.limit = Number(value()) else if (arg === '--page') options.page = Number(value()) else if (arg === '--out') options.out = value() @@ -74,8 +76,9 @@ export const parseArchiveCliArgs = (argv = process.argv.slice(2)) => { } if (options.help) return options - if (options.apply && options.verify) { - throw new Error(`--apply e --verify são mutuamente exclusivos — ${USAGE}.`) + const modes = [options.apply, options.verify, options.refreshMetadata].filter(Boolean).length + if (modes > 1) { + throw new Error(`--apply, --verify e --refresh-metadata são mutuamente exclusivos — ${USAGE}.`) } if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) { throw new Error('--limit deve ser >= 1.') @@ -83,6 +86,11 @@ export const parseArchiveCliArgs = (argv = process.argv.slice(2)) => { if (!Number.isInteger(options.page) || options.page < 1) { throw new Error('--page deve ser >= 1.') } + if (options.refreshMetadata && options.page !== 1) { + throw new Error( + `--page não se aplica a --refresh-metadata (a varredura é das linhas do acervo) — ${USAGE}.`, + ) + } if (options.out.split(/[\\/]/).includes('..')) { throw new Error('--out não pode escapar do diretório do repo (sem "..").') } @@ -274,6 +282,22 @@ export const summarizeArchiveResults = (results) => ({ })), }) +/** + * The `--refresh-metadata` results: rows updated/unchanged, the EXIF entries + * written and the named failures — no row is silently dropped. + * + * @param {Array<{ status: string, flickrId: string, entries?: number | null, error?: string | null }>} results + */ +export const summarizeArchiveRefresh = (results) => ({ + updated: results.filter((result) => result.status === 'updated').length, + unchanged: results.filter((result) => result.status === 'unchanged').length, + failed: results.filter((result) => result.status === 'failed').length, + entries: results.reduce((total, result) => total + (Number(result.entries) || 0), 0), + failures: results + .filter((result) => result.status === 'failed') + .map((result) => ({ flickrId: result.flickrId, error: result.error ?? null })), +}) + /** * The `--verify` inventory over the stored rows (read-only, no Flickr, no * bucket): counts, bytes, per-album coverage, metadata gaps and rows without @@ -388,3 +412,19 @@ export const formatArchiveInventory = (inventory) => { } return lines } + +/** `--refresh-metadata` human lines (rows touched + named failures). */ +export const formatArchiveRefresh = (report) => { + const label = REPORT_LABEL + const lines = [ + `${label} refresh (metadados): atualizadas: ${report.summary.updated} · inalteradas: ${report.summary.unchanged} · falhas: ${report.summary.failed} · entradas EXIF: ${report.summary.entries}`, + `${label} tempo: ${(report.durationMs / 1000).toFixed(1)}s`, + ] + if (report.summary.failures.length > 0) { + lines.push(`${label} falhas:`) + for (const failure of report.summary.failures) { + lines.push(` - ${failure.flickrId}: ${failure.error ?? 'erro'}`) + } + } + return lines +} diff --git a/src/lib/archivePhoto.ts b/src/lib/archivePhoto.ts index 0ceaf10e..00f7c69c 100644 --- a/src/lib/archivePhoto.ts +++ b/src/lib/archivePhoto.ts @@ -175,6 +175,19 @@ export const archivePhotoGeoFrom = ( return { latitude: lat, longitude: lon } } +/** + * The `clean`/`raw` leaves of the JSON API answer arrive wrapped as + * `{ _content: "…" }` (Flickr's XML heritage); plain strings appear in older + * shapes and fixtures. Both are accepted, everything else drops. + */ +const contentScalar = (value: unknown): string | null => { + if (typeof value === 'string') return trimmed(value) + if (value !== null && typeof value === 'object') { + return trimmed((value as Record)._content) + } + return null +} + /** * Normalizes `flickr.photos.getExif` (when it answered) into the stored json: * one entry per tag, empty values dropped, deterministic order. @@ -187,7 +200,7 @@ export const archivePhotoExifEntries = (value: unknown): ArchivePhotoExifEntry[] const entry = raw as Record const tag = trimmed(entry.tag) ?? trimmed(entry.tagspace) const label = trimmed(entry.label) ?? tag - const value = trimmed(entry.clean) ?? trimmed(entry.raw) + const value = contentScalar(entry.clean) ?? contentScalar(entry.raw) if (tag === null || label === null || value === null) continue entries.push({ tag, label, value }) } @@ -250,10 +263,7 @@ export const archivePhotoImportFromFlickr = ({ const originalUrl = original ?? fallback if (originalUrl === null) return { ok: false, reason: 'original' } - const description = - item.description !== null && typeof item.description === 'object' - ? trimmed((item.description as Record)._content) - : trimmed(item.description) + const description = contentScalar(item.description) const license = licenseOrNull(item.license) diff --git a/src/utilities/flickr/archivePhotoIngest.ts b/src/utilities/flickr/archivePhotoIngest.ts index 3823e052..6895651f 100644 --- a/src/utilities/flickr/archivePhotoIngest.ts +++ b/src/utilities/flickr/archivePhotoIngest.ts @@ -2,7 +2,12 @@ import 'server-only' import type { Payload } from 'payload' -import { ARCHIVE_PHOTO_SLUG, archivePhotoAlt, type ArchivePhotoImport } from '@/lib/archivePhoto' +import { + ARCHIVE_PHOTO_SLUG, + archivePhotoAlt, + type ArchivePhotoExifEntry, + type ArchivePhotoImport, +} from '@/lib/archivePhoto' import type { ArchivePhoto } from '@/payload-types' import { withPayloadTransaction } from '@/utilities/payloadTransaction' @@ -142,3 +147,38 @@ export const ingestArchivePhoto = async ( } } } + +export type ArchivePhotoExifUpdateResult = + | { status: 'updated'; flickrId: string; id: number; entries: number } + | { status: 'failed'; flickrId: string; error: string } + +/** + * C231 — rewrites ONLY the stored `exif` of one existing row: the + * `--refresh-metadata` backfill for rows ingested before the mapper understood + * the API's `{ _content }` leaves. The Flickr answer is the source of truth (an + * empty answer clears the field), the deterministic order of + * `archivePhotoExifEntries` makes an unchanged row comparable by the caller, + * and every failure is returned, never thrown — the caller owns the receipt. + * Same documented CLI bypass as the ingestion (trusted actor, no session). + */ +export const updateArchivePhotoExif = async ( + payload: Payload, + { id, flickrId, exif }: { id: number; flickrId: string; exif: ArchivePhotoExifEntry[] }, +): Promise => { + try { + await payload.update({ + collection: ARCHIVE_PHOTO_SLUG, + id, + data: { exif }, + // Intentional bypass: the maintenance CLI is a trusted actor with no session. + overrideAccess: true, + }) + return { status: 'updated', flickrId, id, entries: exif.length } + } catch (error) { + return { + status: 'failed', + flickrId, + error: error instanceof Error ? error.message : String(error), + } + } +} diff --git a/tests/int/archivePhotoIngest.int.spec.ts b/tests/int/archivePhotoIngest.int.spec.ts index 2ede9f11..e04cccc2 100644 --- a/tests/int/archivePhotoIngest.int.spec.ts +++ b/tests/int/archivePhotoIngest.int.spec.ts @@ -13,6 +13,7 @@ import { findArchivePhotoByFlickrId, ingestArchivePhoto, listArchivePhotos, + updateArchivePhotoExif, } from '@/utilities/flickr/archivePhotoIngest' import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture' @@ -156,6 +157,47 @@ describe('ingestArchivePhoto (C231)', () => { expect(await findArchivePhotoByFlickrId(payload, item.flickrId)).toBeNull() }) + it('refreshes only the stored exif of an existing row (C231 backfill)', async () => { + const item = record('53123456794', { exif: [] }) + const created = await ingestArchivePhoto(payload, item, { + filePath: await writeOriginal(item.flickrId), + }) + if (created.status !== 'created') throw new Error('archive photo fixture was not created') + + const refreshed = await updateArchivePhotoExif(payload, { + id: created.id, + flickrId: item.flickrId, + exif: [ + { tag: 'Make', label: 'Fabricante', value: 'Canon' }, + { tag: 'Model', label: 'Modelo', value: 'Canon EOS R6' }, + ], + }) + expect(refreshed).toMatchObject({ status: 'updated', entries: 2 }) + + const found = await payload.find({ + collection: ARCHIVE_PHOTO_SLUG, + where: { flickrId: { equals: item.flickrId } }, + depth: 0, + limit: 1, + overrideAccess: true, + }) + expect(found.docs[0]?.exif).toEqual([ + { tag: 'Make', label: 'Fabricante', value: 'Canon' }, + { tag: 'Model', label: 'Modelo', value: 'Canon EOS R6' }, + ]) + // Only the exif changed: the metadata ingested with the original stays. + expect(found.docs[0]?.title).toBe(item.title) + + const failed = await updateArchivePhotoExif(payload, { + id: 9_999_999, + flickrId: '53123456799', + exif: [], + }) + expect(failed).toMatchObject({ status: 'failed', flickrId: '53123456799' }) + if (failed.status !== 'failed') return + expect(failed.error.length).toBeGreaterThan(0) + }) + it('reads only with the communication roles', async () => { const item = record('53123456792') await ingestArchivePhoto(payload, item, { filePath: await writeOriginal(item.flickrId) }) diff --git a/tests/unit/archivePhoto.unit.spec.ts b/tests/unit/archivePhoto.unit.spec.ts index 764afc5b..ef80c3a8 100644 --- a/tests/unit/archivePhoto.unit.spec.ts +++ b/tests/unit/archivePhoto.unit.spec.ts @@ -139,6 +139,27 @@ describe('archivePhotoExifEntries (C231)', () => { ]) expect(archivePhotoExifEntries(null)).toEqual([]) }) + + it("unwraps the JSON API's { _content } leaves and still accepts plain strings", () => { + expect( + archivePhotoExifEntries([ + { tag: 'Make', label: 'Fabricante', raw: { _content: 'Canon' } }, + { + tag: 'Model', + label: 'Modelo', + raw: { _content: 'Canon EOS' }, + clean: { _content: 'Canon EOS R6' }, + }, + { tag: 'Artist', label: 'Autor', raw: { _content: ' ' } }, + { tag: 'Unknown', label: 'Desconhecido', raw: { outro: 'x' } }, + { tag: 'Software', label: 'Software', clean: 'Tezza' }, + ]), + ).toEqual([ + { tag: 'Make', label: 'Fabricante', value: 'Canon' }, + { tag: 'Model', label: 'Modelo', value: 'Canon EOS R6' }, + { tag: 'Software', label: 'Software', value: 'Tezza' }, + ]) + }) }) describe('archivePhotoImportFromFlickr (C231)', () => { diff --git a/tests/unit/flickrImportCli.unit.spec.ts b/tests/unit/flickrImportCli.unit.spec.ts index 97f2bc8e..cd1c404e 100644 --- a/tests/unit/flickrImportCli.unit.spec.ts +++ b/tests/unit/flickrImportCli.unit.spec.ts @@ -74,6 +74,34 @@ describe('flickr:import write guards (C231)', () => { expect(output(result)).toContain('S3_BUCKET') }) + it('refuses --refresh-metadata without the intent flag, before the key check', () => { + const result = run(['--refresh-metadata']) + + expect(result.status).toBe(1) + expect(output(result)).toContain('FLICKR_IMPORT_CONFIRM=1') + expect(output(result)).toContain('--refresh-metadata') + expect(output(result)).not.toContain('FLICKR_API_KEY') + }) + + it('requires the declared TEQO_ENV for --refresh-metadata once the intent flag is set', () => { + const result = run(['--refresh-metadata'], { FLICKR_IMPORT_CONFIRM: '1' }) + + expect(result.status).toBe(1) + expect(output(result)).toContain('TEQO_ENV') + }) + + it('does not demand S3 for the metadata-only refresh (dies on the missing key instead)', () => { + const result = run(['--refresh-metadata'], { + FLICKR_IMPORT_CONFIRM: '1', + TEQO_ENV: 'production', + FLICKR_API_KEY: '', + }) + + expect(result.status).toBe(1) + expect(output(result)).toContain('FLICKR_API_KEY') + expect(output(result)).not.toContain('S3_BUCKET') + }) + it('refuses an unknown argument before touching anything', () => { const result = run(['--force']) diff --git a/tests/unit/flickrPlan.unit.spec.ts b/tests/unit/flickrPlan.unit.spec.ts index 6b097c08..e71b7c2e 100644 --- a/tests/unit/flickrPlan.unit.spec.ts +++ b/tests/unit/flickrPlan.unit.spec.ts @@ -8,11 +8,13 @@ import { collectArchiveListing, formatArchiveBytes, formatArchiveInventory, + formatArchiveRefresh, formatArchiveReport, parseArchiveCliArgs, planArchiveEntries, summarizeArchiveInventory, summarizeArchivePlan, + summarizeArchiveRefresh, summarizeArchiveResults, } from '../../scripts/lib/flickrPlan.mjs' @@ -209,6 +211,23 @@ describe('summaries (C231)', () => { }) }) + it('summarizes the refresh results with the entries written and failures', () => { + expect( + summarizeArchiveRefresh([ + { flickrId: '1', status: 'updated', entries: 42 }, + { flickrId: '2', status: 'unchanged', entries: 0 }, + { flickrId: '3', status: 'failed', error: 'HTTP 500' }, + { flickrId: '4', status: 'updated', entries: 7 }, + ]), + ).toEqual({ + updated: 2, + unchanged: 1, + failed: 1, + entries: 49, + failures: [{ flickrId: '3', error: 'HTTP 500' }], + }) + }) + it('summarizes the stored inventory per album and per metadata gap', () => { const inventory = summarizeArchiveInventory([ { @@ -310,6 +329,25 @@ describe('receipt formatting (C231)', () => { expect(formatArchiveBytes(2 * 1024 * 1024)).toBe('2.0 MiB') }) + it('formats the refresh report with the touched rows and the failures', () => { + const lines = formatArchiveRefresh({ + durationMs: 2500, + summary: { + updated: 120, + unchanged: 10, + failed: 1, + entries: 4200, + failures: [{ flickrId: '9', error: 'HTTP 500' }], + }, + }) + const text = lines.join('\n') + expect(text).toContain('refresh (metadados): atualizadas: 120') + expect(text).toContain('inalteradas: 10') + expect(text).toContain('entradas EXIF: 4200') + expect(text).toContain('tempo: 2.5s') + expect(text).toContain('- 9: HTTP 500') + }) + it('stamps the report file name', () => { expect(archivePhotoReportStamp('2026-09-26T06:20:39.123Z')).toBe('2026-09-26T06-20-39-123Z') }) @@ -320,6 +358,7 @@ describe('parseArchiveCliArgs (C231)', () => { expect(parseArchiveCliArgs([])).toEqual({ apply: false, verify: false, + refreshMetadata: false, limit: null, page: 1, out: 'data/flickr', @@ -332,11 +371,27 @@ describe('parseArchiveCliArgs (C231)', () => { parseArchiveCliArgs(['--apply', '--limit', '5', '--page', '3', '--out', 'data/fotos']), ).toMatchObject({ apply: true, verify: false, limit: 5, page: 3, out: 'data/fotos' }) expect(parseArchiveCliArgs(['--verify'])).toMatchObject({ verify: true, apply: false }) + expect(parseArchiveCliArgs(['--refresh-metadata'])).toMatchObject({ + refreshMetadata: true, + apply: false, + verify: false, + }) + expect(parseArchiveCliArgs(['--refresh-metadata', '--limit', '5'])).toMatchObject({ + refreshMetadata: true, + limit: 5, + }) expect(parseArchiveCliArgs(['--help'])).toMatchObject({ help: true }) }) it('refuses exclusive modes, invalid numbers, escaping --out and unknown flags', () => { expect(() => parseArchiveCliArgs(['--apply', '--verify'])).toThrow(/mutuamente exclusivos/) + expect(() => parseArchiveCliArgs(['--apply', '--refresh-metadata'])).toThrow( + /mutuamente exclusivos/, + ) + expect(() => parseArchiveCliArgs(['--verify', '--refresh-metadata'])).toThrow( + /mutuamente exclusivos/, + ) + expect(() => parseArchiveCliArgs(['--refresh-metadata', '--page', '2'])).toThrow(/--page/) expect(() => parseArchiveCliArgs(['--limit', '0'])).toThrow(/--limit/) expect(() => parseArchiveCliArgs(['--limit', 'abc'])).toThrow(/--limit/) expect(() => parseArchiveCliArgs(['--page', '0'])).toThrow(/--page/)