diff --git a/docs/changelog/2026-10-02-c249.md b/docs/changelog/2026-10-02-c249.md new file mode 100644 index 00000000..c9f50b16 --- /dev/null +++ b/docs/changelog/2026-10-02-c249.md @@ -0,0 +1 @@ +**C249 — archive:publish com preflight de integridade antes de aprovar drafts (2026-10-02):** o lote de aprovação do acervo deixa de confiar cegamente no `publicationStatus` e passa a inspecionar cada draft pelo MESMO dono da varredura do C246 (`inspectPrivateMediaObject` + `resolvePrivateMediaStaticDir`, import dinâmico como no sweep): objeto baixado pelo caminho da rota pública e decodificado com sharp antes de qualquer `payload.update`. Objeto fora de `ok` **não é aprovado** — fica `draft`, entra no recibo como `skippedBroken` (id, stage `missing`/`download`/`decode`, motivo) e o comando sai **1**; o modo plano (sem `--apply`) também inspeciona (read-only) e sai 1 se houver quebrados, para o operador ver antes. A fila passa a selecionar `filename` além de `id`; o lote vira o seam exportado `runArchivePublishBatch` (temp por foto removido no `finally`, sem cache entre plan/apply, progresso a cada 200) e o script ganha guard de entrypoint `isDirectRun`, então o int importa o caminho real sem tocar o banco inteiro. O puro `scripts/lib/archivePublishPlan.mjs` ganha `skippedBroken`/`skippedBrokenCount` no summary e as linhas `! foto fora do lote (): ` no recibo; pulada nunca conta como falha de update. Guardas do publish intactas (`ARCHIVE_PUBLISH_CONFIRM` + `TEQO_ENV`; sem S3 novo/flag nova — sem S3 o alvo não-local classifica tudo `missing` e nada aprova, fail-closed). Sem migration, sem UI, sem contrato público; `scripts/publish-archive-photos.mjs` entra no `SCRIPTS_SPEC_PINNED` (high-risk exato → unit/int full + e2e curado). Runbook C242/C246 atualizado (o preflight nomeia/pula; a varredura segue sendo quem repara). Testes: unit do summarize/format com `skippedBroken` e do pin de help; int sobre `teqo_test` com objeto íntegro aprovado, corrompido `decode` e removido `missing` fora do lote (recibo nomeando) e plano sem escrita. Impl plan: `docs/plans/acervo-publish-preflight-integridade-impl.md`. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index b75efbbe..85ca01a5 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -1528,12 +1528,20 @@ aparelho do visitante (o navegador envia só o vetor). A flag ``` (dry-run sem `--apply` mostra o estado; o guard exige `TEQO_ENV` casando o banco exato.) -3. **Aprovar o acervo** (drafts → approved; `removed` nunca é tocado): +3. **Aprovar o acervo** (drafts → approved; `removed` nunca é tocado). Desde o + C249 cada draft passa pelo **preflight de integridade** do C246 antes de + aprovar: o objeto é baixado e decodificado pelo mesmo caminho da rota + pública; objeto ausente/corrompido fica **fora do lote**, é nomeado no recibo + (`skippedBroken` com stage + motivo) e o comando sai **1**. O modo plano + (sem `--apply`) também inspeciona (read-only) e sai 1 se houver quebrados; o + custo do lote é download+decode de toda a fila, então comece pelo canário: ```bash + docker compose --profile maintenance run --rm \ + -e TEQO_ENV=production -e ARCHIVE_PUBLISH_CONFIRM=1 \ + teqo-1313-migrate pnpm archive:publish --apply --limit 20 # canário docker compose --profile maintenance run --rm \ -e TEQO_ENV=production -e ARCHIVE_PUBLISH_CONFIRM=1 \ teqo-1313-migrate pnpm archive:publish --apply - # canário: --limit 20 ``` Ao fim, buste a tag pública em um processo que a possui (o write do CLI não revalida o servidor Next): @@ -1734,13 +1742,17 @@ curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \ Fotos reparadas que continuam `approved` voltam a servir sozinhas (mesma chave, rota `force-dynamic`) — e o índice facial reprocessa sozinho o que tiver -marcador stale (`pnpm faces:index`). Uma foto rebaixada a `draft` **não** deve -voltar em lote: o `pnpm archive:publish` aprova todo draft sem checar o objeto, -então só rode o publish depois de uma varredura limpa (a foto rebaixada só sai -de draft quando a fonte for recuperada e o operador decidir publicá-la). +marcador stale (`pnpm faces:index`). Uma foto rebaixada a `draft` **não volta +por engano** no lote: desde o C249 o `pnpm archive:publish` inspeciona cada +draft pelo mesmo `inspectPrivateMediaObject` desta varredura antes de aprovar — +a quebrada fica fora do lote, sai nomeada no recibo (`skippedBroken`) e o +comando termina 1. O aviso antigo ("só rode o publish depois de uma varredura +limpa") deixou de ser a única proteção; ainda vale rodar a varredura limpa +primeiro, porque ela é quem **repara** (o publish não repara, só pula e nomeia). Rollback: sem migration nesta entrega; o comando nunca apaga objeto nem linha — uma foto rebaixada por engano volta a `approved` pela ficha no admin (com o -canal de remoção configurado). +canal de remoção configurado) ou pelo publish depois de o objeto inspecionar +limpo. ## C230-followup — importação do Instagram para a Central de Conteúdos diff --git a/docs/plans/acervo-publish-preflight-integridade-impl.md b/docs/plans/acervo-publish-preflight-integridade-impl.md new file mode 100644 index 00000000..22bcb8e6 --- /dev/null +++ b/docs/plans/acervo-publish-preflight-integridade-impl.md @@ -0,0 +1,156 @@ +# Impl: C249 — archive:publish: preflight de integridade antes de aprovar drafts + +Status: aprovado (modo --auto) +Atualizado em: 2026-10-02 +Issue: #1427 +Intenção: docs/plans/acervo-publish-preflight-integridade.md +Appetite restante: herdado (~0,5 dia eng; sem migration, sem UI, sem contrato público). Custo distribuído ~70% preflight/CLI, ~20% provas, ~10% runbook. + +## Leitura da intenção + +- **Outcome:** `pnpm archive:publish` (plan e `--apply`) passa cada draft elegível pelo `inspectPrivateMediaObject` do C246; objeto fora de `ok` **não é aprovado**, entra no recibo como `skippedBroken` (id, stage, reason) e o comando sai 1 se houver puladas — o lote nunca devolve mídia quebrada ao público. +- **O que NÃO negociar (aceite inviolável):** aplicar nunca aprova draft com objeto quebrado; recibo nomeia cada pulada com stage/motivo; exit 1 se houver puladas; int cobre aprovadas e puladas; guardas do publish intactos (`ARCHIVE_PUBLISH_CONFIRM`/`TEQO_ENV`); runbook atualizado. Não reparar objeto (dono é C246), não tocar `removed`, não varrer outras collections/vídeos, sem migration/campo novo, sem cache/revalidação além do publish atual. +- **O que reavaliar (hipóteses da direção):** + - A composição do sweep (import dinâmico da utility + `resolvePrivateMediaStaticDir` + `inspect` injetado) realmente é reusável no publish sem novo módulo de varredura (D1). + - O plano também inspeciona (custo O(fila) de download+decode) — confirmado pela intenção, mas registrar o custo (D4). + - Como o int prova a orquestração sem varrer o banco de teste inteiro — o script precisa de um seam importável (D6). + - Publish não exige S3 hoje; sem S3 num alvo não-local toda a fila vira `missing` (fail-closed) — decidir se vira guarda nova (D7). + +## Abordagem recomendada + +```mermaid +flowchart LR + CMD["pnpm archive:publish
[--apply] [--limit n]"] --> ENV{mode} + ENV -->|apply| G["guardas atuais:
ARCHIVE_PUBLISH_CONFIRM + TEQO_ENV"] + ENV -->|plan| Q + G --> Q["payload.find drafts
select id + filename"] + Q --> PF["preflight: inspectPrivateMediaObject
por draft (temp por foto, finally cleanup)"] + PF --> OK{"status == ok?"} + OK -->|"ok + apply"| UPD["payload.update
publicationStatus: approved"] + OK -->|"missing/corrupt"| SKIP["result: skippedBroken
{photoId, stage, reason}"] + OK -->|"ok + plan"| PREV["preview elegível"] + UPD --> SUM["summarizeArchivePublishResults
approved | failed | failures | skippedBroken"] + SKIP --> SUM + SUM --> REP["formatArchivePublishReport + recibo JSON"] + REP --> EXIT{"skippedBrokenCount > 0?"} + EXIT -->|sim| E1["die → exit 1"] + EXIT -->|"não (apply)"| E2{"approved == 0 e results > 0?"} + E2 -->|sim| E3["die (comportamento atual)"] + E2 -->|não| E0["exit 0"] +``` + +**Opções consideradas:** A) compor o preflight dentro do `publish-archive-photos.mjs` reusando o dono da varredura C246 e estender a lib pura só com summarize/format | B) criar um módulo/lib novo de varredura no publish | C) import estático da utility no topo do script. + +**Recomendação:** A — o C246 já é o dono do _como_ inspecionar (download pelo caminho de serviço + decode sharp); o publish só precisa do _quando_ (antes de aprovar) e do recibo. Manter `scripts/lib/archivePublishPlan.mjs` puro preserva o padrão unit-testável do repo (fakes dirigem summarize/format) e o import dinâmico da utility evita abrir S3 no boot do CLI. + +**Rejeitadas:** B porque duplica o dono da varredura e cria um segundo caminho de inspeção que pode divergir do sweep; C porque a utility puxa `server-only`/S3/sharp no boot — o sweep documenta exatamente essa armadilha com o import dinâmico (`scripts/check-archive-integrity.mjs:195-196`). + +### Decisões de engenharia + +**D1. Onde vive o preflight.** +Opções: A) função/loop no próprio `scripts/publish-archive-photos.mjs`, com o import dinâmico de `inspectPrivateMediaObject`/`resolvePrivateMediaStaticDir` e `inspect` injetado | B) novo `scripts/lib/archivePublishScan.mjs` | C) import estático da utility. +Recomendação: A — o dono do _probe_ continua sendo `src/utilities/privateMedia/privateMediaResponse.ts:335`; o publish reusa a mesma composição do sweep (`dynamic import` + `resolvePrivateMediaStaticDir(payload, 'archivePhoto')` + injeção de `inspect`), sem varredura própria. +Rejeitadas: B duplica o dono e a composição; C quebra o boot sem S3/local disk path. + +**D2. Forma do resultado e do recibo.** +Opções: A) estender `results` com `{photoId, status:'skippedBroken', stage:'missing'|'download'|'decode', reason}` e `summary` com `skippedBroken` (array) + `skippedBrokenCount` | B) campo novo no schema (`publicationStatus: 'broken'` ou similar) | C) só log no console, sem recibo. +Recomendação: A — o `skippedBroken` vive no recibo (JSON + linhas), não no schema; `summarizeArchivePublishResults` passa a ramificar por status (hoje qualquer não-`approved` vira `failed`, `archivePublishPlan.mjs:55-61`) para que pulada nunca seja contada como falha. +Rejeitadas: B exige migration desnecessária e cria estado novo de publicação; C não atende o aceite ("recibo nomeia cada pulada"). + +**D3. Exit code e interação com o `die` atual.** +Opções: A) após gravar o recibo, `skippedBrokenCount > 0` → `die(...)` com mensagem específica, antes da checagem legada `approved === 0 && results.length > 0` | B) `process.exitCode = 1` silencioso | C) manter exit 0 com puladas. +Recomendação: A — `die` (`dieWithLabel`, `scripts/lib/cli.mjs:18-21`) já é o padrão de falha do CLI, imprime o label e sai 1; como o recibo é gravado antes, nada se perde. `approved > 0` com puladas continua saindo 1 (aceite). A checagem legada permanece como fallback para falhas puras de update. No modo plano, o exit 1 por puladas também vale (o operador/CI precisa do sinal antes do `--apply`), depois de imprimir o recibo. +Rejeitadas: B não explica o motivo no stderr; C viola o aceite. + +**D4. Plano também inspeciona.** +Opções: A) sim, com o mesmo preflight no modo plano | B) só no `--apply`, plano lista a fila sem olhar o objeto | C) inspecionar no plano apenas com `--limit` (canário). +Recomendação: A — a intenção pede explicitamente "e no plano, para o operador ver antes"; o plano continua read-only (nenhum `payload.update`), mas paga download+decode de toda a fila. O custo é assumido: o recibo do plano usa o mesmo `summary` da lib pura (o censo `eligible` não conta como falha; `approved`/`failed` ficam 0) e o `--limit` segue como canário. Cortar o preflight do plano contrariaria a intenção — não cortar. +Rejeitadas: B falha o pedido explícito do operador; C faz o caminho default mentir (o operador não vê o que o apply faria). + +**D5. Arquivos temporários.** +Opções: A) temp por foto, removido no `finally`, dono do diretório é a própria função de lote (espelho de `inspectRow` do sweep, `scripts/check-archive-integrity.mjs:137-158`) | B) cache de um diretório compartilhado entre plan/apply | C) temp dentro do repo (`data/archive/tmp`). +Recomendação: A — cada foto é baixada para `/` e o diretório é removido no `finally`; o `tempRoot` (`mkdtemp(tmpdir(), 'archive-publish-')`) é criado e removido pela função. Sem cache entre modos: o objeto pode mudar entre o plan e o apply (é exatamente o risco que o preflight cobre). +Rejeitadas: B introduz stale state justamente na janela que importa; C polui o repo e corre risco de sobra em crash. + +**D6. Seam de teste do lote (int).** +Opções: A) exportar `runArchivePublishBatch({ payload, queue, inspect, staticDir, apply, log })` do próprio `scripts/publish-archive-photos.mjs` e guardar `main()`/`loadCliEnv()` com `isDirectRun` (`import.meta.url` × `pathToFileURL(process.argv[1])`) | B) spawnar a CLI contra `teqo_test` e ler o recibo | C) duplicar o loop no teste. +Recomendação: A — o int dirige o lote real (Payload real + `inspectPrivateMediaObject` real + `resolvePrivateMediaStaticDir` real) com a **própria fila**, sem tocar drafts de outros specs; o padrão de script importável já existe no repo (`scripts/build-chart-from-data.mjs:301`, `scripts/copy-face-vision-assets.mjs:52-56` e o unit `tests/unit/buildChartFromData.unit.spec.ts:9`). +Rejeitadas: B é frágil — o int roda em forks paralelos (`vitest.config.mts:31-51`) e a CLI aprovaria _todos_ os drafts do banco, interferindo em specs vizinhos (ex.: os estados do `archiveIntegrity.int.spec.ts`); C testa uma cópia, não o caminho de produção. + +**D7. Guarda de S3 no publish.** +Opções: A) não adicionar guarda nova; manter as guardas atuais e documentar no runbook que o publish roda no maintenance com o env do stack (como o sweep) | B) exigir as 4 `S3_*` em alvo não-local, espelhando `mirroredMediaRequired` do sweep | C) exigir S3 só no `--apply`. +Recomendação: A — o não escopo da intenção proíbe mexer nas guardas/confirm; sem S3 o fallback é o disco local do container (vazio em produção), então todo draft classifica `missing`, nada é aprovado e o comando sai 1 — ou seja, o fail-closed já existe e é o lado seguro. O runbook fixa o modo de execução correto. A guarda B fica como gatilho de revisitação se houver operador rodando publish fora do maintenance. +Rejeitadas: B amplia contrato de CLI não pedido (e o alvo local de dev/test não tem S3 de propósito); C deixa o plan medir o storage errado silenciosamente. + +### Componentes / mudanças + +- **`summarizeArchivePublishResults`** (`scripts/lib/archivePublishPlan.mjs:50`): aceita os 3 status; `approved`/`failed`/`failures` como hoje e `skippedBroken` (array de `{photoId, stage, reason}`) + `skippedBrokenCount`; nunca conta pulada como falha. Pura, sem I/O. +- **`formatArchivePublishReport`** (`scripts/lib/archivePublishPlan.mjs:66`): no apply imprime `aprovadas: N | falharam: M | puladas (quebradas): K` e uma linha por pulada com `foto fora do lote (): `; no plan imprime `drafts quebrados (fora do lote): K` + as mesmas linhas (sem `approved`/`failed`). Tolerante a summary sem as chaves legadas (`?.`). +- **`runArchivePublishBatch`** (novo export de `scripts/publish-archive-photos.mjs`): recebe `{ payload, queue, inspect, staticDir, apply, log }`; para cada foto cria `/`, injeta `media: { filename: photo.filename ?? null }`, remove o diretório no `finally` e retorna `results` (`approved` via `payload.update` com `overrideAccess` como hoje, `failed` no catch do update, `skippedBroken` vindo do inspect com `stage`/`reason`). `apply:false` classifica sem escrever (usado pelo plan). Progresso a cada `PAGE_SIZE` (200) via `log` injetável. +- **`main`** (`scripts/publish-archive-photos.mjs:94`): fila `payload.find` passa a `select: { id: true, filename: true }` (`:136`); após o boot, import dinâmico de `{ inspectPrivateMediaObject, resolvePrivateMediaStaticDir }` de `src/utilities/privateMedia/privateMediaResponse.ts` e `staticDir = resolvePrivateMediaStaticDir(payload, 'archivePhoto')`; slug via `ARCHIVE_PHOTO_SLUG` (`src/lib/archivePhoto.ts:14`). Plan e apply montam `summary = summarizeArchivePublishResults(results)` (no plano o censo `eligible` é ignorado; `approved`/`failed` ficam 0) e `preview` inalterado (`{photoId}`). Exit: `skippedBrokenCount > 0` → `die` específico (plan e apply); depois a checagem legada `approved === 0 && results.length > 0`; senão `process.exit(0)`. +- **Entrypoint guard** (mesmo script): `loadCliEnv()` e `main().catch(die)` passam para dentro de `if (isDirectRun)` (`pathToFileURL`), tornando o import pelo int livre de efeito colateral. Help atualizado: o texto dos modos declara o preflight ("objetos inspecionados; draft quebrado fica fora do lote e é nomeado no recibo; sai 1 se houver puladas"). +- **Migration:** nenhuma (sem schema, sem campo novo). +- **Access / Consent:** nenhum; o lote continua trusted operator (`overrideAccess: true`, `scripts/publish-archive-photos.mjs:137-138`), sem sessão e sem Consent. +- **UI:** nenhuma. +- **Testes:** `tests/unit/archivePublishPlan.unit.spec.ts` (casos novos de summarize/format); novo `tests/int/archivePublish.int.spec.ts` modelado em `tests/int/archiveIntegrity.int.spec.ts` (`vi.mock('next/cache')`, `ARCHIVE_PHOTO_JPEG_BYTES`, `ingestArchivePhoto`, `resolvePrivateMediaStaticDir`, corromper com `writeFile`/remover com `rm`, limpeza em `afterAll`); `tests/unit/faceCli.unit.spec.ts:96-128` permanece como pin dos spawns de guarda (extensão só da asserção de help). +- **Docs:** `docs/ops/teqo-1313-deploy.md` — passo 3 do C242 (`:1531-1543`) e aviso do C246 (`:1735-1743`); changelog `docs/changelog/2026-10-02-c249.md`. +- **CI:** editar `scripts/lib/archivePublishPlan.mjs` (SCRIPTS_SPEC_PINNED, `scripts/lib/test-affected-core.mjs:49`) marca high-risk (`:131-165`) → unit/int full + e2e curado; sem edição de manifest. + +### Dados → forma (se aplicável) + +- Forma escolhida: o recibo JSON mantém `results` como array na ordem da fila e `summary.skippedBroken` como array de `{photoId, stage, reason}` + contagem explícita `skippedBrokenCount` para a linha humana. Ordem cronológica preserva a leitura do operador e o diff do recibo; a contagem evita `reduce` no formatador. +- Rejeitadas: mapa aninhado por stage (perde a ordem e complica o recibo), `reason` só no log (aceite exige nomeação no recibo), incluir bytes/URL no recibo (PII/superfície desnecessária). + +## Fases verificáveis + +1. **Preflight + CLI — quota ~70% (~0,35 dia).** `summarizeArchivePublishResults`/`formatArchivePublishReport` com a chave nova; `runArchivePublishBatch` + guard de entrypoint; fila com `filename`; import dinâmico da utility; report/exit do plan e do apply; help. Prova: `pnpm test:unit -- tests/unit/archivePublishPlan.unit.spec.ts` e `pnpm gate:fast` verdes (os spawns de guarda existentes seguem passando sem edição de comportamento). +2. **Provas — quota ~20% (~0,1 dia).** Unit: contar/formatar `skippedBroken` nos dois modos e garantir que pulada não entra em `failures`. Int `tests/int/archivePublish.int.spec.ts`: fixture íntegro → aprovado no banco e em `results`; fixture com objeto sobrescrito por lixo → permanece `draft` com `skippedBroken {stage:'decode', reason não vazio}`; fixture com objeto removido → permanece `draft` com `stage:'missing'`; `summarize`/`format` nomeiam os ids com stage/motivo. Prova: `pnpm test:int -- tests/int/archivePublish.int.spec.ts` verde + `pnpm test:unit -- tests/unit/faceCli.unit.spec.ts` (guardas intactos). +3. **Runbook + gates — quota ~10% (~0,05 dia).** Atualizar o aviso do C246 (o publish agora inspeciona antes de aprovar; draft quebrado fica fora e é nomeado) e o passo 3 do C242 (custo do preflight, `--limit` canário, exit 1 com puladas, execução no maintenance com o env do stack); changelog. Gates: `pnpm gate:fast`; `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Reparar objeto no publish (é C246) — só pular e nomear. +- Tocar `removed` (a query continua `publicationStatus = draft`) ou varrer `media`/vídeos/outras collections. +- Mudar guardas/confirm (D7): nada de `S3_*` novo, nada de flag nova. +- Migration/campo novo: `skippedBroken` vive no recibo. +- Cache/revalidação: o publish segue só imprimindo o curl manual; sem `revalidateTag` no CLI. +- `--concurrency`/`--only`/retry no publish: canário continua `--limit`; loop sequencial como hoje — revisitar só se a parede de tempo do lote completo (~6,5k) virar problema real (registrado no runbook). +- Unificar sweep e publish num "framework" de varredura: duplica/abstrai dono sem volatilidade. + +## Riscos e mitigação + +- **Custo do apply sobe de updates de banco para download+decode de toda a fila:** `--limit` canário, log de progresso a cada 200, expectativa documentada no runbook; o lote é one-off operado no maintenance. +- **Produção sem S3 no processo:** todo objeto vira `missing` e nada é aprovado (fail-closed, exit 1) — o runbook fixa a execução no maintenance com o env file do stack (mesmo requisito do sweep); sem guarda nova por não escopo (D7). +- **Import do script pelo int executar `main`/`loadCliEnv`:** guard `isDirectRun` cobre; o teste importa só a função. +- **Int aprovando drafts de specs vizinhos:** o seam recebe a fila explicitamente; nunca roda a CLI sobre o banco inteiro (D6). +- **`filename` nulo:** classifica `missing` pelo dono C246 (`privateMediaResponse.ts:362`) — draft sem objeto nunca aprova. +- **Regressão de contagem:** unit pina que `skippedBroken` não entra em `failed`/`failures`. +- **Exit 1 no plan surpreender:** mensagem clara + runbook. +- **PII no recibo:** apenas id/stage/reason; sem bytes, sem URL, sem nome. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: apply nunca aprova objeto quebrado; recibo nomeia cada pulada com stage/motivo; exit 1 se houver puladas; int cobre aprovadas e puladas. +- [ ] Invariantes AGENTS/engineering-standards: sem migration/schema, sem Consent/access novo, sem mudança de URL pública; guardas do publish intactos; identificadores em inglês; sem PII no recibo. +- [ ] Testes de domínio previstos: unit da lib pura (summarize/format com `skippedBroken`), int com Payload real (aprovado × pulado com stage/motivo), spawns de guarda existentes verdes. +- [ ] Gates: `pnpm gate:fast` verde; int local do spec novo; `pnpm push`. +- [ ] Runbook (`teqo-1313-deploy.md` C242/C246) e changelog `docs/changelog/2026-10-02-c249.md` atualizados. +- [ ] CI ciente: high-risk por `scripts/lib/archivePublishPlan.mjs` pinado roda unit/int full + e2e curado sem edição de manifest. + +## Self-score decision-quality + +| Critério | Nota | Justificativa | +| ----------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Decisões caras têm rejeitadas? | 5 | D1–D7 registram Opções/Recomendação/Rejeitadas, inclusive o seam de teste e a não-guarda de S3. | +| 2. Abordagem cabe no appetite da intenção? | 5 | Sem migration/UI/contrato; ~0,5 dia com preflight reusando o dono C246 e a lib pura existente. | +| 3. Rabbit holes nomeados? | 5 | Reparo, `removed`, schema, concorrência, cache e "framework" de varredura estão explicitamente fora, com gatilho de revisitação para o custo. | +| 4. Depth check: reusa shells/helpers existentes? | 4 | Reusa `inspectPrivateMediaObject`/`resolvePrivateMediaStaticDir`, a lib pura e os padrões de int/spawn; a única fronteira nova é o export testável + guard de entrypoint, justificado por D6. | +| 5. Intenção (aceite de produto) permanece satisfeita? | 5 | A engenharia não reescreveu o outcome: só o caminho de aprovação ganhou o portão de integridade e o recibo/exit correspondentes. | + +## Débitos diferidos (triage do /simplify, 2026-10-02) + +Todos score ≤3 (cheap_polish/defer_trigger) — registrados aqui com gatilho, sem Issue nova: + +- **Probe por foto duplicado** (`inspectPhoto` no publish × `inspectRow` no sweep C246, `scripts/check-archive-integrity.mjs:137`): extrair um helper compartilhado só quando houver 3º consumidor ou mudança na composição `inspect`/`tempRoot` (D5 manteve a separação). +- **Sem guarda `S3_*` no publish** (D7): em alvo não-local sem S3 o preflight mede o disco local; o fail-closed (`missing` → nada aprova) só não cobre o caso estreito de o `staticDir` local conter mídia. Gatilho: operador rodando publish fora do maintenance ou `staticDir` local com mídia em alvo não-local → exigir as 4 `S3_*` como o sweep faz. +- **Exit 1 com puladas sem prova automatizada da fiação do `die`**: unit cobre summarize/format, int cobre o seam, faceCli cobre guardas; falta um spawn-test do plan/apply com `skippedBrokenCount > 0`. Gatilho: próximo toque no bloco report/exit do publish ou padronização de spawn-test de exit code no repo. diff --git a/scripts/lib/archivePublishPlan.mjs b/scripts/lib/archivePublishPlan.mjs index 0f3a42ee..16e27229 100644 --- a/scripts/lib/archivePublishPlan.mjs +++ b/scripts/lib/archivePublishPlan.mjs @@ -1,7 +1,8 @@ /** * C242 — pure planning/reporting of `pnpm archive:publish`: the argv parser, * the run summary and the human lines + JSON receipt. No I/O of its own, so the - * unit spec drives it with fakes. + * unit spec drives it with fakes. C249 — the summary/receipt also carry the + * integrity preflight verdicts (`skippedBroken`). */ const USAGE = 'uso: `pnpm archive:publish [--apply] [--limit ] [--out ]`' @@ -45,33 +46,66 @@ export const parseArchivePublishCliArgs = (argv = process.argv.slice(2)) => { } /** - * @param {Array<{ photoId: number, status: string, error?: string | null }>} results + * C249 — the preflight verdict never counts as an update failure: a draft whose + * object is missing/corrupt is `skippedBroken` (stage + reason), kept in its own + * list so the receipt names it without pretending an approval was attempted. + * `eligible` is the plan-mode census (inspected clean, no write) and is + * deliberately not a failure — plan and apply receive the same results, so the + * summary composes for both modes. + * + * @param {Array<{ photoId: number, status: string, stage?: string | null, reason?: string | null, error?: string | null }>} results */ export const summarizeArchivePublishResults = (results) => { const failures = [] + const skippedBroken = [] let approved = 0 let failed = 0 for (const result of results) { - if (result.status === 'approved') approved += 1 - else { + if (result.status === 'approved') { + approved += 1 + } else if (result.status === 'skippedBroken') { + skippedBroken.push({ + photoId: result.photoId, + stage: result.stage ?? null, + reason: result.reason ?? null, + }) + } else if (result.status === 'eligible') { + continue + } else { failed += 1 failures.push({ photoId: result.photoId, error: result.error ?? null }) } } - return { approved, failed, failures } + return { + approved, + failed, + failures, + skippedBroken, + skippedBrokenCount: skippedBroken.length, + } } export const formatArchivePublishReport = (report) => { const lines = [ `[archive:publish] modo: ${report.mode} | alvo: ${report.target}`, - `[archive:publish] drafts elegíveis: ${report.queue.totalDrafts}`, + `[archive:publish] fila: ${report.queue.items} de ${report.queue.totalDrafts} draft(s)`, ] - if (report.mode !== 'plan') { + if (report.mode === 'plan') { lines.push( - `[archive:publish] aprovadas: ${report.summary.approved} | falharam: ${report.summary.failed}`, + `[archive:publish] drafts quebrados (fora do lote): ${report.summary?.skippedBrokenCount ?? 0}`, + ) + } else { + lines.push( + `[archive:publish] aprovadas: ${report.summary?.approved ?? 0} | falharam: ${report.summary?.failed ?? 0} | puladas (quebradas): ${report.summary?.skippedBrokenCount ?? 0}`, + ) + } + + for (const skipped of report.summary?.skippedBroken ?? []) { + lines.push( + `[archive:publish] ! foto ${skipped.photoId} fora do lote (${skipped.stage ?? 'missing'}): ${skipped.reason ?? 'objeto ausente'}`, ) } diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 519ac13b..e715e9c7 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -33,6 +33,7 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/check-test-locations.mjs', 'scripts/cityReportSnapshot.mjs', 'scripts/institutionSnapshot.mjs', + 'scripts/publish-archive-photos.mjs', 'scripts/themeSnapshot.mjs', 'scripts/lib/agent-forgejo.mjs', 'scripts/lib/agent-plan-lifecycle.mjs', diff --git a/scripts/publish-archive-photos.mjs b/scripts/publish-archive-photos.mjs index 21474978..2a1da905 100644 --- a/scripts/publish-archive-photos.mjs +++ b/scripts/publish-archive-photos.mjs @@ -5,9 +5,17 @@ * (decision of the owner, 2026-10-01); `removed` is NEVER touched — a takedown * stays a takedown. * + * C249 — every eligible draft goes through the C246 integrity probe before any + * approval: the object is downloaded through the same serving path and decoded; + * a missing/undecodable object stays `draft`, is named in the receipt as + * `skippedBroken` (stage + reason) and the command exits 1. The runbook warning + * stopped being the only protection. + * * Modes: - * pnpm archive:publish plan/dry-run (default) — the eligible queue; no write - * pnpm archive:publish --apply approves the drafts; requires + * pnpm archive:publish plan/dry-run (default) — the eligible + * queue + preflight; no write + * pnpm archive:publish --apply approves the drafts whose object + * inspects clean; requires * ARCHIVE_PUBLISH_CONFIRM=1 outside local dev * and the declared TEQO_ENV matching the * exact database @@ -23,10 +31,14 @@ * curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \ * -H "x-revalidate-secret: $REVALIDATE_SECRET" */ +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { getPayload } from 'payload' +import { ARCHIVE_PHOTO_SLUG } from '../src/lib/archivePhoto.ts' import { isArchivePhotoRemovalChannelUrl } from '../src/lib/archivePhotoCatalog.ts' import { @@ -46,8 +58,6 @@ import { writeRepoFile, } from './lib/cli.mjs' -loadCliEnv() - const die = dieWithLabel('archive:publish') const WRITE_CONFIRM_FLAG = 'ARCHIVE_PUBLISH_CONFIRM' @@ -63,9 +73,14 @@ Uso: pnpm archive:publish [opções] Aprova em lote as fotos DRAFT do acervo (publicationStatus: draft → approved), para o álbum público e a busca por selfie. Fotos "removed" nunca são tocadas. +Antes de aprovar, cada draft passa pelo preflight de integridade do C246: o +objeto é baixado pelo mesmo caminho da rota pública e decodificado. Objeto +ausente/corrompido fica fora do lote, é nomeado no recibo (stage + motivo) e o +comando sai 1. + Modos: - (sem flag) plano/dry-run — fila elegível; sem escrita - --apply aprova os drafts (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local) + (sem flag) plano/dry-run — fila elegível + preflight; sem escrita + --apply aprova os drafts com objeto íntegro (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local) Opções: --limit processa no máximo n fotos (canário) @@ -79,9 +94,9 @@ Ambiente: casando o banco exato. O plano é read-only. ` -const writeReport = async (options, report, suffix = '') => { +const writeReport = async (options, report) => { const stamp = archivePublishReportStamp(report.runAt) - const relativePath = join(options.out, 'reports', `archive-publish-${stamp}${suffix}.json`) + const relativePath = join(options.out, 'reports', `archive-publish-${stamp}.json`) await writeRepoFile({ label: 'archive:publish', root: process.cwd(), @@ -91,6 +106,104 @@ const writeReport = async (options, report, suffix = '') => { return join(process.cwd(), relativePath) } +const emitReport = async (options, report, hint = null) => { + const reportPath = await writeReport(options, report) + for (const line of formatArchivePublishReport(report)) console.log(line) + if (hint) console.log(`\n${hint}`) + console.log(`[archive:publish] recibo JSON: ${reportPath}`) +} + +/** + * C249 — one read-only preflight probe: download + decode through the SAME + * owner the C246 sweep uses, with a temp dir created and removed per photo. + * Nothing here writes to the archive; the verdict is `ok` or `skippedBroken`. + */ +const inspectPhoto = async ({ photo, tempRoot, staticDir, inspect }) => { + const dir = join(tempRoot, String(photo.id)) + await mkdir(dir, { recursive: true }) + const filename = photo.filename ?? null + try { + const inspection = await inspect({ + media: { filename }, + staticDir, + destinationPath: join(dir, filename ?? `foto-${photo.id}.bin`), + }) + if (inspection.status === 'ok') return { photoId: photo.id, status: 'ok' } + return { + photoId: photo.id, + status: 'skippedBroken', + stage: inspection.status === 'missing' ? 'missing' : inspection.stage, + reason: inspection.status === 'missing' ? 'objeto ausente' : inspection.reason, + } + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => undefined) + } +} + +/** + * C249 — the batch, exported so the int spec drives the real Payload/utility + * path with its own queue (never the whole database). Every photo is + * preflighted before any approval; `apply: false` classifies without writing. + * The receipt owns the human/JSON naming — this function only returns verdicts. + * + * @param {object} options + * @param {import('payload').Payload} options.payload + * @param {Array<{ id: number, filename?: string | null }>} options.queue + * @param {Function} options.inspect `inspectPrivateMediaObject` + * @param {string} options.staticDir local fallback dir of the collection + * @param {boolean} options.apply whether the clean drafts are approved + * @param {(line: string) => void} [options.log] + * @returns {Promise>} + */ +export const runArchivePublishBatch = async ({ + payload, + queue, + inspect, + staticDir, + apply, + log = console.log, +}) => { + const results = [] + const tempRoot = await mkdtemp(join(tmpdir(), 'archive-publish-')) + try { + for (let index = 0; index < queue.length; index += 1) { + const photo = queue[index] + const preflight = await inspectPhoto({ photo, tempRoot, staticDir, inspect }) + + if (preflight.status !== 'ok') { + results.push(preflight) + } else if (!apply) { + results.push({ photoId: photo.id, status: 'eligible' }) + } else { + try { + await payload.update({ + collection: ARCHIVE_PHOTO_SLUG, + id: photo.id, + data: { publicationStatus: 'approved' }, + depth: 0, + // Intentional bypass: same trusted operator. + overrideAccess: true, + }) + results.push({ photoId: photo.id, status: 'approved' }) + } catch (error) { + results.push({ + photoId: photo.id, + status: 'failed', + error: error?.message ?? String(error), + }) + } + } + + if ((index + 1) % PAGE_SIZE === 0) { + log(`[archive:publish] … ${index + 1}/${queue.length}`) + } + } + } finally { + await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined) + } + return results +} + async function main() { const options = parseArchivePublishCliArgs(process.argv.slice(2)) if (options.help) { @@ -114,6 +227,8 @@ async function main() { } const config = (await import('../src/payload.config.ts')).default + const { inspectPrivateMediaObject, resolvePrivateMediaStaticDir } = + await import('../src/utilities/privateMedia/privateMediaResponse.ts') const payload = await getPayload({ config }) const startedAt = Date.now() @@ -127,66 +242,53 @@ async function main() { } const drafts = await payload.find({ - collection: 'archivePhoto', + collection: ARCHIVE_PHOTO_SLUG, where: { publicationStatus: { equals: 'draft' } }, sort: ['id'], depth: 0, limit: 0, pagination: false, - select: { id: true }, + select: { id: true, filename: true }, // Intentional bypass: the publish CLI is a trusted operator with no session. overrideAccess: true, }) const queue = options.limit ? drafts.docs.slice(0, options.limit) : drafts.docs + if (mode === 'apply' && queue.length === 0) { + console.log('[archive:publish] nada a fazer — nenhuma foto draft elegível.') + process.exit(0) + } + + const staticDir = resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG) + const results = await runArchivePublishBatch({ + payload, + queue, + inspect: inspectPrivateMediaObject, + staticDir, + apply: mode === 'apply', + }) + + const summary = summarizeArchivePublishResults(results) + if (mode === 'plan') { const report = { runAt, mode, target: databaseTarget(), queue: { totalDrafts: drafts.docs.length, items: queue.length }, - summary: { approved: 0, failed: 0, failures: [] }, + summary, preview: queue.slice(0, 20).map((doc) => ({ photoId: doc.id })), durationMs: Date.now() - startedAt, } - const reportPath = await writeReport(options, report) - for (const line of formatArchivePublishReport(report)) console.log(line) - console.log(`\n[archive:publish] recibo JSON: ${reportPath}`) + await emitReport(options, report) + if (summary.skippedBrokenCount > 0) { + die( + `${summary.skippedBrokenCount} foto(s) draft com objeto quebrado/ausente — fora do lote; veja o recibo.`, + ) + } process.exit(0) } - if (queue.length === 0) { - console.log('[archive:publish] nada a fazer — nenhuma foto draft elegível.') - process.exit(0) - } - - const results = [] - for (let index = 0; index < queue.length; index += 1) { - const photo = queue[index] - try { - await payload.update({ - collection: 'archivePhoto', - id: photo.id, - data: { publicationStatus: 'approved' }, - depth: 0, - // Intentional bypass: same trusted operator. - overrideAccess: true, - }) - results.push({ photoId: photo.id, status: 'approved' }) - } catch (error) { - results.push({ - photoId: photo.id, - status: 'failed', - error: error?.message ?? String(error), - }) - } - - if ((index + 1) % PAGE_SIZE === 0) { - console.log(`[archive:publish] … ${index + 1}/${queue.length}`) - } - } - - const summary = summarizeArchivePublishResults(results) const report = { runAt, mode, @@ -195,19 +297,28 @@ async function main() { summary, durationMs: Date.now() - startedAt, } - const reportPath = await writeReport(options, report) - for (const line of formatArchivePublishReport(report)) console.log(line) - console.log( - '\n[archive:publish] lembre de bustar a tag pública (processo Next): curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" -H "x-revalidate-secret: $REVALIDATE_SECRET"', + await emitReport( + options, + report, + '[archive:publish] lembre de bustar a tag pública (processo Next): curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" -H "x-revalidate-secret: $REVALIDATE_SECRET"', ) - console.log(`[archive:publish] recibo JSON: ${reportPath}`) + if (summary.skippedBrokenCount > 0) { + die( + `${summary.skippedBrokenCount} foto(s) draft com objeto quebrado/ausente ficaram fora do lote — veja o recibo.`, + ) + } if (summary.approved === 0 && results.length > 0) { die('nenhuma foto foi aprovada — falhas listadas no recibo.') } process.exit(0) } -main().catch((error) => { - die(error?.message || String(error)) -}) +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href + +if (isDirectRun) { + loadCliEnv() + main().catch((error) => { + die(error?.message || String(error)) + }) +} diff --git a/tests/int/archivePublish.int.spec.ts b/tests/int/archivePublish.int.spec.ts new file mode 100644 index 00000000..acb20a45 --- /dev/null +++ b/tests/int/archivePublish.int.spec.ts @@ -0,0 +1,200 @@ +// @vitest-environment node + +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('next/cache', () => ({ + revalidatePath: () => undefined, + revalidateTag: vi.fn(), + unstable_cache: (fn: unknown) => fn, +})) + +import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto' +import config from '@/payload.config' +import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest' +import { + inspectPrivateMediaObject, + resolvePrivateMediaStaticDir, +} from '@/utilities/privateMedia/privateMediaResponse' + +import { + formatArchivePublishReport, + summarizeArchivePublishResults, +} from '../../scripts/lib/archivePublishPlan.mjs' +import { runArchivePublishBatch } from '../../scripts/publish-archive-photos.mjs' + +import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture' +import { PHOTO_ALBUM_LEASE_KEY, withExclusiveTestDatabaseLease } from '../helpers/testDatabaseLease' + +// C249 — the publish preflight over the real Payload `teqo_test`: a clean draft +// is approved, a draft whose object is corrupt/removed stays `draft` and is +// named in the receipt (stage + reason), and the plan mode classifies without +// writing. The batch seam receives its own queue, so no other spec's drafts are +// ever approved by this run. + +let payload: Payload +const createdFlickrIds = new Set() +const tempDirs: string[] = [] + +const CHANNEL = 'https://jorgesolla.example.org/remocao' + +const record = ( + flickrId: string, + overrides: Partial = {}, +): ArchivePhotoImport => ({ + flickrId, + sourceUrl: `https://www.flickr.com/photos/depjorgesolla/${flickrId}/`, + owner: '12345678@N00', + license: '0', + title: `Foto ${flickrId}`, + description: 'Registro do mandato', + tags: [], + takenAt: '2025-03-14 10:20:30', + postedAt: '2025-03-15T10:00:00.000Z', + albums: [], + geo: null, + exif: [], + originalUrl: `https://live.staticflickr.com/65535/${flickrId}_abcdef_o.jpg`, + originalKind: 'original', + ...overrides, +}) + +const tempDir = async (prefix: string): Promise => { + const dir = await mkdtemp(join(tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +const createPhoto = async (): Promise<{ id: number; flickrId: string; filename: string }> => { + const flickrId = `c249-${randomUUID().slice(0, 8)}` + createdFlickrIds.add(flickrId) + const dir = await tempDir('archive-publish-') + const filePath = join(dir, `flickr-${flickrId}.jpg`) + await writeFile(filePath, ARCHIVE_PHOTO_JPEG_BYTES) + const created = await ingestArchivePhoto(payload, record(flickrId), { filePath }) + if (created.status !== 'created' || !created.filename) { + throw new Error(`fixture not created: ${created.status}`) + } + return { id: created.id, flickrId, filename: created.filename } +} + +const staticDir = (): string => resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG) +const storedPath = (filename: string): string => join(staticDir(), filename) + +const runBatch = (queue: Array<{ id: number; filename?: string | null }>, apply: boolean) => + runArchivePublishBatch({ + payload, + queue, + inspect: inspectPrivateMediaObject, + staticDir: staticDir(), + apply, + log: () => undefined, + }) + +const statusOf = async (id: number): Promise => { + const doc = await payload.findByID({ + collection: ARCHIVE_PHOTO_SLUG, + id, + depth: 0, + overrideAccess: true, + }) + return doc.publicationStatus +} + +const setChannel = async (removalChannelUrl: string | null): Promise => { + await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () => + payload.updateGlobal({ + slug: 'photoAlbum', + data: { published: removalChannelUrl !== null, removalChannelUrl }, + overrideAccess: true, + }), + ) +} + +describe('archive publish preflight (C249)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + await setChannel(CHANNEL) + }) + + afterAll(async () => { + for (const flickrId of createdFlickrIds) { + await payload.delete({ + collection: ARCHIVE_PHOTO_SLUG, + where: { flickrId: { equals: flickrId } }, + overrideAccess: true, + }) + } + await setChannel(null) + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + }) + + it('approves a draft whose object inspects clean', async () => { + const photo = await createPhoto() + + const results = await runBatch([photo], true) + + expect(results).toEqual([{ photoId: photo.id, status: 'approved' }]) + expect(await statusOf(photo.id)).toBe('approved') + }) + + it('keeps a corrupt object out of the batch and names the decode stage in the receipt', async () => { + const photo = await createPhoto() + await writeFile(storedPath(photo.filename), Buffer.from('isto não é uma imagem')) + + const results = await runBatch([photo], true) + + expect(results).toHaveLength(1) + const result = results[0] + if (!result) throw new Error('batch result missing') + expect(result).toMatchObject({ + photoId: photo.id, + status: 'skippedBroken', + stage: 'decode', + }) + expect(typeof result.reason).toBe('string') + if (typeof result.reason === 'string') { + expect(result.reason.length).toBeGreaterThan(0) + } + expect(await statusOf(photo.id)).toBe('draft') + + const summary = summarizeArchivePublishResults(results) + expect(summary.approved).toBe(0) + expect(summary.failed).toBe(0) + expect(summary.skippedBrokenCount).toBe(1) + const receipt = formatArchivePublishReport({ + mode: 'apply', + target: 'test', + queue: { totalDrafts: 1, items: 1 }, + summary, + }).join('\n') + expect(receipt).toContain(`! foto ${photo.id} fora do lote (decode):`) + expect(receipt).toContain('puladas (quebradas): 1') + }) + + it('keeps a draft with a removed object out of the batch as missing', async () => { + const photo = await createPhoto() + await rm(storedPath(photo.filename), { force: true }) + + const results = await runBatch([photo], true) + + expect(results).toEqual([ + { photoId: photo.id, status: 'skippedBroken', stage: 'missing', reason: 'objeto ausente' }, + ]) + expect(await statusOf(photo.id)).toBe('draft') + }) + + it('classifies a clean draft without writing in plan mode', async () => { + const photo = await createPhoto() + + const results = await runBatch([photo], false) + + expect(results).toEqual([{ photoId: photo.id, status: 'eligible' }]) + expect(await statusOf(photo.id)).toBe('draft') + }) +}) diff --git a/tests/unit/archivePublishPlan.unit.spec.ts b/tests/unit/archivePublishPlan.unit.spec.ts index 685e45c2..c837a7cd 100644 --- a/tests/unit/archivePublishPlan.unit.spec.ts +++ b/tests/unit/archivePublishPlan.unit.spec.ts @@ -50,6 +50,39 @@ describe('summarizeArchivePublishResults', () => { expect(summary.failed).toBe(1) expect(summary.failures).toEqual([{ photoId: 3, error: 'canal ausente' }]) }) + + it('keeps a preflight skip out of the failures and names its stage', () => { + const summary = summarizeArchivePublishResults([ + { photoId: 1, status: 'approved' }, + { photoId: 4, status: 'skippedBroken', stage: 'missing', reason: 'objeto ausente' }, + { photoId: 5, status: 'skippedBroken', stage: 'decode', reason: 'Input buffer contains' }, + { photoId: 6, status: 'failed', error: 'canal ausente' }, + ]) + + expect(summary.approved).toBe(1) + expect(summary.failed).toBe(1) + expect(summary.failures).toEqual([{ photoId: 6, error: 'canal ausente' }]) + expect(summary.skippedBroken).toEqual([ + { photoId: 4, stage: 'missing', reason: 'objeto ausente' }, + { photoId: 5, stage: 'decode', reason: 'Input buffer contains' }, + ]) + expect(summary.skippedBrokenCount).toBe(2) + }) + + it('treats the plan-mode eligible census and the download stage honestly', () => { + const summary = summarizeArchivePublishResults([ + { photoId: 1, status: 'eligible' }, + { photoId: 2, status: 'skippedBroken', stage: 'download', reason: 'checksum divergente' }, + ]) + + expect(summary.approved).toBe(0) + expect(summary.failed).toBe(0) + expect(summary.failures).toEqual([]) + expect(summary.skippedBroken).toEqual([ + { photoId: 2, stage: 'download', reason: 'checksum divergente' }, + ]) + expect(summary.skippedBrokenCount).toBe(1) + }) }) describe('formatting', () => { @@ -63,7 +96,7 @@ describe('formatting', () => { summary: { approved: 0, failed: 0, failures: [] }, }).join('\n') expect(plan).toContain('modo: plan') - expect(plan).toContain('drafts elegíveis: 6492') + expect(plan).toContain('fila: 6492 de 6492 draft(s)') const apply = formatArchivePublishReport({ mode: 'apply', @@ -74,4 +107,38 @@ describe('formatting', () => { expect(apply).toContain('aprovadas: 2 | falharam: 1') expect(apply).toContain('✗ foto 7: sem arquivo') }) + + it('names every skipped broken draft in the receipt lines (C249)', () => { + const apply = formatArchivePublishReport({ + mode: 'apply', + target: 'host/db', + queue: { totalDrafts: 10, items: 4 }, + summary: { + approved: 2, + failed: 0, + failures: [], + skippedBroken: [ + { photoId: 8, stage: 'missing', reason: 'objeto ausente' }, + { photoId: 9, stage: 'decode', reason: 'Input buffer contains unsupported image format' }, + ], + skippedBrokenCount: 2, + }, + }).join('\n') + expect(apply).toContain('aprovadas: 2 | falharam: 0 | puladas (quebradas): 2') + expect(apply).toContain('! foto 8 fora do lote (missing): objeto ausente') + expect(apply).toContain('! foto 9 fora do lote (decode): Input buffer contains') + + const plan = formatArchivePublishReport({ + mode: 'plan', + target: 'host/db', + queue: { totalDrafts: 10, items: 4 }, + summary: { + skippedBroken: [{ photoId: 8, stage: 'missing', reason: 'objeto ausente' }], + skippedBrokenCount: 1, + }, + }).join('\n') + expect(plan).toContain('drafts quebrados (fora do lote): 1') + expect(plan).toContain('! foto 8 fora do lote (missing): objeto ausente') + expect(plan).not.toContain('aprovadas:') + }) }) diff --git a/tests/unit/faceCli.unit.spec.ts b/tests/unit/faceCli.unit.spec.ts index da9da10d..1e5a3d36 100644 --- a/tests/unit/faceCli.unit.spec.ts +++ b/tests/unit/faceCli.unit.spec.ts @@ -100,6 +100,7 @@ describe('archive:publish write guards (C242)', () => { expect(result.status).toBe(0) expect(result.stdout).toContain('ARCHIVE_PUBLISH_CONFIRM') expect(result.stdout).toContain('--limit') + expect(result.stdout).toContain('preflight de integridade') }) it('refuses --apply on a production target without the intent flag', () => {