feat(archive): preflight de integridade antes de aprovar drafts (C249)

O pnpm archive:publish passa cada draft elegível pelo inspectPrivateMediaObject do C246 (download pelo caminho da rota pública + decode sharp) antes de qualquer payload.update: objeto missing/corrupt fica fora do lote, é nomeado no recibo como skippedBroken (stage + motivo) e o comando sai 1 — no plan (read-only) e no apply. A fila seleciona filename além de id; o lote vira o seam exportado runArchivePublishBatch (temp por foto removido no finally) com guard isDirectRun, e o puro scripts/lib/archivePublishPlan.mjs ganha skippedBroken/skippedBrokenCount no summary e as linhas do recibo. scripts/publish-archive-photos.mjs entra no SCRIPTS_SPEC_PINNED (high-risk exato: unit/int full + e2e curado). Guardas do publish intactas; sem S3 num alvo não-local o probe classifica tudo missing (fail-closed). Sem migration, sem UI. Runbook C242/C246 e changelog atualizados; unit do summarize/format, int do lote real (íntegro aprovado, corrompido decode, removido missing, plan sem escrita) e impl plan em docs/plans/acervo-publish-preflight-integridade-impl.md.
This commit is contained in:
Francisco Solla 2026-10-02 16:54:02 -03:00
parent ac39c81765
commit a3a49c3462
9 changed files with 653 additions and 70 deletions

View file

@ -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 <id> fora do lote (<stage>): <reason>` 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`.

View file

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

View file

@ -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<br/>[--apply] [--limit n]"] --> ENV{mode}
ENV -->|apply| G["guardas atuais:<br/>ARCHIVE_PUBLISH_CONFIRM + TEQO_ENV"]
ENV -->|plan| Q
G --> Q["payload.find drafts<br/>select id + filename"]
Q --> PF["preflight: inspectPrivateMediaObject<br/>por draft (temp por foto, finally cleanup)"]
PF --> OK{"status == ok?"}
OK -->|"ok + apply"| UPD["payload.update<br/>publicationStatus: approved"]
OK -->|"missing/corrupt"| SKIP["result: skippedBroken<br/>{photoId, stage, reason}"]
OK -->|"ok + plan"| PREV["preview elegível"]
UPD --> SUM["summarizeArchivePublishResults<br/>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 `<tempRoot>/<photoId>` 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 <id> fora do lote (<stage>): <reason>`; 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 `<tempRoot>/<photoId>`, 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.

View file

@ -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 <n>] [--out <dir>]`'
@ -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'}`,
)
}

View file

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

View file

@ -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 <n> 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<Array<{ photoId: number, status: 'ok' | 'eligible' | 'approved' | 'failed' | 'skippedBroken', stage?: string, reason?: string, error?: string }>>}
*/
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))
})
}

View file

@ -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<string>()
const tempDirs: string[] = []
const CHANNEL = 'https://jorgesolla.example.org/remocao'
const record = (
flickrId: string,
overrides: Partial<ArchivePhotoImport> = {},
): 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<string> => {
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<string> => {
const doc = await payload.findByID({
collection: ARCHIVE_PHOTO_SLUG,
id,
depth: 0,
overrideAccess: true,
})
return doc.publicationStatus
}
const setChannel = async (removalChannelUrl: string | null): Promise<void> => {
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')
})
})

View file

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

View file

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