fix(C231): EXIF do acervo volta ao mapper e ganha backfill --refresh-metadata

A API do Flickr responde raw/clean como { _content } e o mapper só aceitava
string — as 6.492 fotos do backfill gravaram exif: []. archivePhotoExifEntries
desembrulha os dois shapes (contentScalar também substitui o unwrap ad-hoc do
description) e o CLI ganha o modo --refresh-metadata, que re-busca o EXIF das
linhas existentes e atualiza só o que mudou: write-only no campo exif, sem S3,
guardas de confirmação/TEQO_ENV e recibo atualizadas/inalteradas/falhas.
Runbook §C231 documenta a operação. Closes #1383.
This commit is contained in:
Francisco Solla 2026-09-29 08:48:21 -03:00
parent 59a6480df8
commit ad2b165e5e
10 changed files with 375 additions and 41 deletions

View file

@ -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.

View file

@ -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

View file

@ -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 <n> (canary), --page <n> (listing start page), --out <dir>
* (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 <n> processa no máximo n fotos (a varredura de álbuns continua completa)
--page <n> começa a listagem na página n (retomada)
--limit <n> processa no máximo n fotos; no refresh, no máximo n linhas
--page <n> começa a listagem na página n (retomada; não vale no refresh)
--out <dir> 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 })

View file

@ -31,7 +31,7 @@ const VIDEO_MEDIA = 'video'
const BYTES_PER_MIB = 1024 * 1024
const USAGE =
'uso: `pnpm flickr:import [--apply|--verify] [--limit <n>] [--page <n>] [--out <dir>]`'
'uso: `pnpm flickr:import [--apply|--verify|--refresh-metadata] [--limit <n>] [--page <n>] [--out <dir>]`'
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
}

View file

@ -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<string, unknown>)._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<string, unknown>
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<string, unknown>)._content)
: trimmed(item.description)
const description = contentScalar(item.description)
const license = licenseOrNull(item.license)

View file

@ -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<ArchivePhotoExifUpdateResult> => {
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),
}
}
}

View file

@ -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) })

View file

@ -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)', () => {

View file

@ -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'])

View file

@ -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/)