mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(ops): CLI ops:global para flags de globais públicos (C247)
Grava um único campo do global via Local API e busta a tag no processo Next vivo pelo POST /api/revalidate (allowlist ampliada com REVALIDATE_PHOTO_ALBUM_CACHE_TAG), com guardas de escrita, plan/apply/verify, recibo com rollback e int pin do update parcial.
This commit is contained in:
parent
d8020607b9
commit
269b3d9e3b
10 changed files with 878 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -91,6 +91,8 @@ next-env.d.ts
|
|||
# C232 — photo-archive cataloguing receipts (pnpm archive:catalog)
|
||||
/data/archive/
|
||||
/data/face/
|
||||
# C247 — public-global ops receipts (pnpm ops:global)
|
||||
/data/ops-global/
|
||||
# Solla × Ceuci report HTML cache (scripts/build-solla-ceuci-salvador-report.mjs)
|
||||
/data/solla-ceuci-salvador/
|
||||
|
||||
|
|
|
|||
117
docs/plans/ops-toggle-globais-publicos-impl.md
Normal file
117
docs/plans/ops-toggle-globais-publicos-impl.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Impl: C247 — Ligar/desligar globais públicos fora do admin (revalidação + ops)
|
||||
|
||||
Status: aprovado (--auto)
|
||||
Atualizado em: 2026-10-01
|
||||
Issue: #1419
|
||||
Intenção: docs/plans/ops-toggle-globais-publicos.md
|
||||
Appetite restante: herdado
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome (não negociar):** a ops liga/desliga uma flag pública operacional por CLI — `pnpm ops:global photoAlbum --selfie-search on` — com guardas, recibo e rollback, e o site reflete em ≤60 s sem admin e sem restart. Impeccable A: sem UI.
|
||||
- **O que NÃO negociar:** allowlist fechada (nenhuma tag arbitrária); escrita pela Local API (nunca SQL cru); nenhum segundo caminho de permissão; segredo nunca no log, nem em erro; hook fail-closed do `PhotoAlbum` (publicar sem canal de remoção válido continua recusado); Consent/LGPD intocados.
|
||||
- **O que reavaliar:**
|
||||
- A recomendação de allowlist da intenção (4 globais operacionais, marcada "assumido — validar") — ver D1.
|
||||
- "O `revalidateTag` do processo do CLI basta": não basta — o CLI roda com `seed-loader.mjs:20-29` (stuba `next/cache`) e a instância Next é outro processo; o efeito só existe via `POST /api/revalidate` no alvo.
|
||||
- Update parcial de global: o hook `beforeValidate` global recebe o `data` já mesclado com o documento original no Payload 3.82.0, o que torna o write de um campo único seguro — pinado por int test (ver Componentes).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph OPS["Ops — CLI scripts/ops-global.mjs"]
|
||||
CMD["pnpm ops:global photoAlbum<br/>--selfie-search on [--apply|--verify]"] --> PLAN["tabela fechada (operação→campo)<br/>plan default"]
|
||||
PLAN --> GUARD["guardas: OPS_GLOBAL_CONFIRM<br/>TEQO_ENV↔banco · preflight do bust"]
|
||||
GUARD --> WRITE["payload.updateGlobal<br/>campo único · overrideAccess"]
|
||||
WRITE --> REC["recibo JSON: previous→new<br/>+ rollback (sem segredo)"]
|
||||
end
|
||||
WRITE --> POST["POST /api/revalidate?tag=global_photoAlbum<br/>x-revalidate-secret"]
|
||||
POST --> EP["route.ts + revalidateRequest.ts<br/>allowlist fechada"]
|
||||
EP --> BUST["revalidateTag('global_photoAlbum')<br/>no processo Next vivo"]
|
||||
BUST --> SURF["/fotos · /fotos/encontre · home<br/>≤60 s · sem restart"]
|
||||
POST -. falha .-> ERR["erro alto + curl de recuperação<br/>(recibo registra revalidation)"]
|
||||
```
|
||||
|
||||
**Opções consideradas (macro):** A) ampliar a allowlist do `POST /api/revalidate` (derivando de `getGlobalCacheTag`) e o CLI faz o bust remoto no alvo; B) rota de ops dedicada com o mesmo segredo; C) `revalidateTag` dentro do processo do CLI.
|
||||
**Recomendação:** A — o endpoint já é o dono do bust remoto (auth `timingSafeEqual`, tag por query/body, allowlist testada em `tests/unit/revalidateRequest.unit.spec.ts`); o CLI reusa o padrão de `scripts/lib/instagramContentCli.mjs:129-154`.
|
||||
**Rejeitadas:** B porque cria uma segunda superfície de permissão/rota para o mesmo efeito (anti-goal explícito da intenção: "criar segundo caminho de permissão") e duplica a auth; C porque não existe — `unstable_cache` é por processo, o `seed-loader.mjs:20-29` stuba `next/cache` no CLI (o `revalidateTag` local é no-op) e produção é o container Next vivo.
|
||||
|
||||
### Decisões de engenharia (caro de reverter)
|
||||
|
||||
**D1 — Escopo da allowlist:** A) só `global_photoAlbum` agora, com gatilho de revisitação; B) os 4 operacionais públicos do plano de intenção (`photoAlbum`, `site-settings`, `home`, `metadata`); C) derivar a allowlist de todos os globais do config.
|
||||
**Recomendação:** A — a allowlist é superfície de segurança (`src/utilities/revalidateRequest.ts:32-44`) e só cresce junto com um escritor real: hoje o único caminho de escrita por CLI é o `photoAlbum` (a tabela de operações desta entrega não tem operação para os outros três; entradas extras seriam tags mortas). Mantém o pin que rejeita `global_metadata` (`tests/unit/revalidateRequest.unit.spec.ts:49-57`) como guarda viva de "global fora do escopo é recusado".
|
||||
**Rejeitadas:** B — divergência registrada da recomendação "assumido — validar" da intenção: satisfaz o mesmo aceite com menos superfície e sem tag sem escritor; **gatilho para revisitar** — no mesmo change em que uma operação nova entrar na tabela, a tag do global dela entra na allowlist e no unit. C porque tag arbitrária/derivada do config é exatamente o que o aceite proíbe.
|
||||
|
||||
**D2 — Superfície de escrita do CLI:** A) tabela fechada de operações booleanas (`selfie-search → photoAlbum.selfieSearchEnabled`, `published → photoAlbum.published`), valores `on|off` exatos; B) `--set <field>=<value>` genérico; C) comando one-off `ops:selfie-search`; D) UPDATE via SQL.
|
||||
**Recomendação:** A — o comando não pode setar campo arbitrário; flag nova só entra por edição da tabela (revisável e testável pura em `scripts/lib/`).
|
||||
**Rejeitadas:** B porque coage tipos, aceita campos não operados e não permite guarda por campo (ex.: canal de remoção para publicar); C porque repete guardas/recibo por flag e não estabelece o caminho de primeira classe que é o item; D porque ignora hooks/validações (anti-goal da intenção).
|
||||
|
||||
**D3 — Falha do bust depois da escrita:** A) erro alto (exit 1) + curl de recuperação no stderr e no recibo, com preflight de `NEXT_PUBLIC_SITE_URL`/`REVALIDATE_SECRET` antes de escrever; B) best-effort como o `instagramContentCli` (registra a falha, run segue verde); C) "testar" o endpoint antes da escrita.
|
||||
**Recomendação:** A — o bust é o ponto do comando: falha silenciosa recria exatamente o atrito do C242. O preflight evita a escrita que já nasce sem contrato; se o POST falhar depois, a escrita existe e o recibo carrega o valor anterior e a receita de recuperação.
|
||||
**Rejeitadas:** B porque no import do Instagram a revalidação é acessória — aqui ela é o outcome; C porque o endpoint não tem dry-run: um POST de teste já busta, então o preflight só pode validar presença de envs.
|
||||
|
||||
**D4 — Granularidade do comando:** A) exatamente uma operação por invocação; B) várias flags no mesmo run.
|
||||
**Recomendação:** A — rollback = mesmo comando com o valor anterior (requisito do aceite), recibo 1:1 e parser simples.
|
||||
**Rejeitada:** B por não ter necessidade real hoje e por compor recibo/rollback sem ganho.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`REVALIDATE_PHOTO_ALBUM_CACHE_TAG`** (`src/utilities/revalidateRequest.ts`): novo `= getGlobalCacheTag('photoAlbum')`, incluído em `ALLOWED_REVALIDATE_TAGS` e exportado para o pin unit. A rota (`src/app/(frontend)/api/revalidate/route.ts`) não muda: o dono da allowlist é este módulo.
|
||||
- **`OPS_GLOBAL_TARGETS`** (`scripts/lib/opsGlobal.mjs`, novo — puro, sem I/O): tabela fechada `photoAlbum → { 'selfie-search': { field: 'selfieSearchEnabled', label }, published: { field: 'published', label } }`; nada fora dela é escrito.
|
||||
- **`parseOpsGlobalCliArgs`** (`scripts/lib/opsGlobal.mjs`): positional `photoAlbum`, exatamente 1 operação, valor `on|off` exato, modos `plan` (default) | `--apply` | `--verify` (`--apply` e `--verify` mutuamente exclusivos), `--help`; erro em desconhecido/valor inválido/múltiplas operações.
|
||||
- **`buildOpsGlobalWrite`** (`scripts/lib/opsGlobal.mjs`): operação+valor+doc atual → `{ data: { [field]: boolean }, previousValue, newValue, changed, rollbackCommand }`; `data` parcial de um único campo.
|
||||
- **`formatOpsGlobalReport` / `opsGlobalReportStamp` / `formatOpsGlobalRecoveryCommand`** (`scripts/lib/opsGlobal.mjs`): linhas humanas, nome do recibo e receitas de rollback/recuperação sem segredo.
|
||||
- **`main()`** (`scripts/ops-global.mjs`, novo): `loadCliEnv`; lê o global (`payload.findGlobal`, `overrideAccess: true` com comentário `bypass`); `plan` imprime previous→new e grava recibo; `--apply` roda `assertWriteConfirm({ flag: 'OPS_GLOBAL_CONFIRM' })` + `assertEnvironmentDatabaseTarget()` (`scripts/lib/cli.mjs:191-320`) + preflight (`NEXT_PUBLIC_SITE_URL`/`REVALIDATE_SECRET` presentes; para `published on`, `isArchivePhotoRemovalChannelUrl` do doc atual, espelho de `scripts/publish-archive-photos.mjs:120-127`), escreve com `payload.updateGlobal`, resolve a tag via `getGlobalCacheTag(slug)` importado de `../src/utilities/globals.ts` e faz `POST {NEXT_PUBLIC_SITE_URL}/api/revalidate?tag=<tag>` com `x-revalidate-secret` e timeout 15 s; falha do POST → erro alto (exit 1) com curl de recuperação; `--verify` relê e compara (exit 1 se divergente); recibo em `data/ops-global/reports/ops-global-<stamp>.json` via `writeRepoFile` (`scripts/lib/cli.mjs`).
|
||||
- **`package.json`**: script `"ops:global"` no padrão dos CLIs (`--import=tsx/esm --import=./scripts/seed-loader.mjs`).
|
||||
- **`.gitignore`**: `/data/ops-global/`.
|
||||
- **Garantia do update parcial (confirmada no código):** `node_modules/payload/dist/globals/operations/update.js` roda o `beforeValidate` de campos antes dos hooks globais, e `getFallbackValue.js` copia o valor do `originalDoc` para campos ausentes — logo `validateRemovalChannel` (`src/globals/PhotoAlbum.ts:22-37`) vê `published`/`removalChannelUrl` mesclados. Pin int novo em `tests/int/archivePhotoPublicRead.int.spec.ts`: parcial `{ selfieSearchEnabled: true }` passa sem apagar os irmãos; parcial `{ published: true }` com canal armazenado inválido continua recusando (fail-closed preservado).
|
||||
- **Migration:** sem migration (nenhum campo/schema novo).
|
||||
- **Access / Consent:** nenhum caminho novo de access; `overrideAccess: true` no script com comentário "bypass" (convenção P3-E; o pin do `codebaseConventions` só varre `src/`); Consent intocado; o guard do `PhotoAlbum` permanece fail-closed.
|
||||
- **UI:** não (Impeccable A) — nenhuma rota ou componente muda; o efeito é cache bust + flag já renderizada por `/fotos`, `/fotos/encontre` e home.
|
||||
- **Runbook** (`docs/ops/teqo-1313-deploy.md`): 2–3 linhas — no passo 5 da Abertura o CLI passa a ser o caminho principal (`--selfie-search on --apply`, admin como alternativa) e a receita de rollback (`--selfie-search off --apply`); nada além.
|
||||
- **Changelog:** `docs/changelog/2026-10-01-c247.md` é do fechamento, não deste plano.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
Não aplicável — a intenção declara "não vou apresentar dados"; o único artefato é o recibo JSON operacional (não é superfície de apresentação).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server** (~0,5 dia — todo o appetite): allowlist + tag export (`revalidateRequest.ts`) → módulo puro + `tests/unit/opsGlobal.unit.spec.ts` (defaults, modos, valor `on|off`, rollback, recibo) → CLI + `package.json` + `.gitignore` → pin int do update parcial → `pnpm test:unit` + rodada targeted do int tocado. Sem migration, sem UI.
|
||||
2. **UI** — n/a (Impeccable A).
|
||||
3. **Gates** — `pnpm gate:fast` (lint + typecheck + unit) e push via `pnpm push`; o CI `checks` roda int/e2e completos.
|
||||
4. **Verificação de efeito (aceite operacional)** — smoke no worktree dev (servidor local + `REVALIDATE_SECRET` local) e/ou staging pós-merge, com a mesma receita do runbook: `--selfie-search on --apply` → espera limitada (≤60 s, sem restart) conferindo `/fotos`, `/fotos/encontre` e a seção da home → `--selfie-search off --apply` (rollback) conferindo o fechamento.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **`revalidateGlobal` → wrapper seguro:** não trocar o owner (`src/utilities/globals.ts:16-17`) nesta entrega; o CLI usa `seed-loader` e o caso genérico de Local API fora do Next já tem `revalidateTagSafely` (`src/utilities/documents.ts:27-34`). **Gatilho:** um consumidor fora do Next que não possa usar o seed-loader.
|
||||
- **Extrair helper compartilhado do POST remoto** (Instagram + este): não agora — são 2 call sites; a regra do repo é encapsular a partir do 3º (**gatilho:** 3º call site ou o próximo toque no CLI do Instagram).
|
||||
- Multi-op por invocação, `--set` genérico, campo não booleano, allowlist dinâmica/ampla, rota de ops dedicada, UI/admin, SQL cru.
|
||||
- E2E: sem benefício — o efeito é cache bust + flag já coberta por `tests/e2e/frontendFotos.e2e.spec.ts` / `frontendFotosSelfie.e2e.spec.ts`; o CLI não roda no `webServer` do Playwright.
|
||||
- Migrations existentes: nenhuma é tocada (não há schema novo).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Bust pós-escrita falha (rede/segredo ausente):** preflight de envs antes de escrever + erro alto + curl de recuperação no stderr e no recibo + rollback documentado; nunca silencioso (difere do best-effort do Instagram por desenho).
|
||||
- **Allowlist cresce por engano:** entrada derivada de `getGlobalCacheTag`; unit aceita `global_photoAlbum` e segue rejeitando `global_metadata`/`global_home`/`global_site-settings`.
|
||||
- **Update parcial apaga irmãos:** `data` de um único campo + int pin do merge do Payload.
|
||||
- **`published on` sem canal:** preflight espelha `publish-archive-photos` e o hook segue fail-closed; o CLI falha antes de escrever com mensagem honesta.
|
||||
- **Drift de tag (typo):** CLI deriva do owner `getGlobalCacheTag`, endpoint usa a constante derivada do mesmo owner, e o unit casa as duas.
|
||||
- **Default `posts` do endpoint (`resolveRevalidateTag`):** o CLI nunca omite a tag — a operação sempre produz `?tag=`; o parser/plano é unit-testado para não gerar receita sem tag.
|
||||
- **Alvo errado (staging × produção):** `assertEnvironmentDatabaseTarget` casa `TEQO_ENV` ao banco e o recibo grava o `target`; o runbook mostra o env file do alvo. O segredo nunca entra no recibo nem no log (nem em erro).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto — comando com guardas + bust via allowlist + efeito nas três superfícies + recibo/rollback; D1 comprovado por unit (tag única) e sem UI.
|
||||
- [ ] Invariantes AGENTS/engineering-standards — Local API (sem SQL), sem novo caminho de access, sem consent novo, `overrideAccess` com comentário "bypass", sem módulo novo top-level em `src/utilities`, sem migration.
|
||||
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — unit da tabela/modos/recibo, unit da allowlist ampliada, int do update parcial fail-closed; e2e justificado como desnecessário.
|
||||
|
||||
## Self-score (decision-quality)
|
||||
|
||||
**5/5**
|
||||
|
||||
1. Decisões caras com rejeitadas: sim — D1 (allowlist), D2 (superfície de escrita), D3 (falha do bust), D4 (granularidade), cada uma com o porquê e a alternativa recusada.
|
||||
2. Cabe no appetite: sim — 1 constante + 1 módulo puro + 1 CLI + 1 script `package.json` + 1 linha de `.gitignore` + 3 testes editados/novos + 2–3 linhas de runbook; ~0,5 dia.
|
||||
3. Rabbit holes nomeados: sim — `revalidateGlobal`, helper DRY <3 call sites, e2e, SQL cru, allowlist dinâmica.
|
||||
4. Depth check: reusa `scripts/lib/cli.mjs` (guardas/`writeRepoFile`), o padrão de recibo de `archivePublishPlan.mjs`, o endpoint e o owner `getGlobalCacheTag`; não cria arquivo/utility nova em `src/`.
|
||||
5. Intenção preservada: sim — o outcome e todos os itens do aceite continuam cobertos; a única divergência (D1: só `photoAlbum` agora) foi registrada com justificativa e gatilho, sem reescrever o outcome.
|
||||
|
|
@ -42,6 +42,7 @@
|
|||
"faces:index": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/index-archive-faces.mjs",
|
||||
"faces:enroll-figure": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/enroll-face-figure.mjs",
|
||||
"archive:publish": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/publish-archive-photos.mjs",
|
||||
"ops:global": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ops-global.mjs",
|
||||
"seed:face-consents": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-consents.mjs",
|
||||
"seed:face-figures": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-figures.mjs",
|
||||
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
|
||||
|
|
|
|||
204
scripts/lib/opsGlobal.mjs
Normal file
204
scripts/lib/opsGlobal.mjs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
/**
|
||||
* C247 — pure planning/reporting of `pnpm ops:global`: the closed table of
|
||||
* operational public-global flags, the argv parser and the receipt helpers.
|
||||
* No I/O of its own, so the unit spec drives it with fakes.
|
||||
*
|
||||
* The table is the write surface AND the HELP source: a flag only becomes
|
||||
* writable by editing it here (reviewable, unit-pinned). Nothing outside it is
|
||||
* ever written.
|
||||
*/
|
||||
|
||||
export const OPS_GLOBAL_DEFAULT_OUT_DIR = 'data/ops-global'
|
||||
|
||||
export const OPS_GLOBAL_WRITE_CONFIRM_FLAG = 'OPS_GLOBAL_CONFIRM'
|
||||
|
||||
const FLAG_VALUE_ON = 'on'
|
||||
const FLAG_VALUE_OFF = 'off'
|
||||
|
||||
const USAGE =
|
||||
'uso: `pnpm ops:global <global> --<flag> <on|off> [--apply|--verify]` — ex.: `pnpm ops:global photoAlbum --selfie-search on`'
|
||||
|
||||
export const OPS_GLOBAL_TARGETS = Object.freeze({
|
||||
photoAlbum: Object.freeze({
|
||||
flags: Object.freeze({
|
||||
'selfie-search': Object.freeze({
|
||||
field: 'selfieSearchEnabled',
|
||||
label: 'Busca por selfie (/fotos/encontre)',
|
||||
defaultValue: false,
|
||||
}),
|
||||
published: Object.freeze({
|
||||
field: 'published',
|
||||
label: 'Álbum público (/fotos)',
|
||||
defaultValue: true,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
/** The HELP operation block, generated from the table — never a second source. */
|
||||
export const formatOpsGlobalOperations = () =>
|
||||
Object.entries(OPS_GLOBAL_TARGETS).flatMap(([slug, target]) =>
|
||||
Object.entries(target.flags).map(
|
||||
([flag, definition]) => ` ${slug} --${flag} <on|off> ${definition.label}`,
|
||||
),
|
||||
)
|
||||
|
||||
export const opsGlobalReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-')
|
||||
|
||||
/** `on`/`off` → boolean; anything else is refused. */
|
||||
const parseFlagValue = (raw, operation) => {
|
||||
if (raw === FLAG_VALUE_ON) return true
|
||||
if (raw === FLAG_VALUE_OFF) return false
|
||||
throw new Error(`valor inválido para --${operation}: "${raw}" (use on|off) — ${USAGE}.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} [argv]
|
||||
* @returns {{ slug: string | null, operation: string | null, value: boolean | null, mode: 'plan' | 'apply' | 'verify', help: boolean }}
|
||||
*/
|
||||
export const parseOpsGlobalCliArgs = (argv = process.argv.slice(2)) => {
|
||||
const options = { slug: null, operation: null, value: null, mode: 'plan', help: false }
|
||||
const positionals = []
|
||||
let requestedOperation = null
|
||||
let requestedValue = null
|
||||
let sawApply = false
|
||||
let sawVerify = false
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
options.help = true
|
||||
continue
|
||||
}
|
||||
if (arg === '--apply') {
|
||||
sawApply = true
|
||||
continue
|
||||
}
|
||||
if (arg === '--verify') {
|
||||
sawVerify = true
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('--')) {
|
||||
if (requestedOperation !== null) {
|
||||
throw new Error(
|
||||
`mais de uma operação (--${requestedOperation} e --${arg.slice(2)}) — ${USAGE}.`,
|
||||
)
|
||||
}
|
||||
requestedOperation = arg.slice(2)
|
||||
const next = argv[++index]
|
||||
if (next === undefined || next.startsWith('--')) {
|
||||
throw new Error(`faltou valor para --${requestedOperation} (on|off) — ${USAGE}.`)
|
||||
}
|
||||
requestedValue = next
|
||||
continue
|
||||
}
|
||||
positionals.push(arg)
|
||||
}
|
||||
|
||||
if (options.help) return options
|
||||
if (sawApply && sawVerify) {
|
||||
throw new Error(`--apply e --verify são mutuamente exclusivos — ${USAGE}.`)
|
||||
}
|
||||
if (positionals.length !== 1) {
|
||||
throw new Error(
|
||||
positionals.length === 0
|
||||
? `informe o global (ex.: photoAlbum) — ${USAGE}.`
|
||||
: `argumento posicional desconhecido: ${positionals.slice(1).join(', ')} — ${USAGE}.`,
|
||||
)
|
||||
}
|
||||
options.slug = positionals[0]
|
||||
if (!Object.hasOwn(OPS_GLOBAL_TARGETS, options.slug)) {
|
||||
throw new Error(
|
||||
`global "${options.slug}" fora da tabela de operações (${Object.keys(OPS_GLOBAL_TARGETS).join(', ')}) — ${USAGE}.`,
|
||||
)
|
||||
}
|
||||
if (requestedOperation === null) {
|
||||
throw new Error(`informe a operação (ex.: --selfie-search on) — ${USAGE}.`)
|
||||
}
|
||||
const flag = OPS_GLOBAL_TARGETS[options.slug].flags[requestedOperation]
|
||||
if (flag === undefined) {
|
||||
throw new Error(
|
||||
`operação "--${requestedOperation}" fora da tabela de ${options.slug} (${Object.keys(OPS_GLOBAL_TARGETS[options.slug].flags).join(', ')}) — ${USAGE}.`,
|
||||
)
|
||||
}
|
||||
|
||||
options.operation = requestedOperation
|
||||
options.value = parseFlagValue(requestedValue, requestedOperation)
|
||||
options.mode = sawApply ? 'apply' : sawVerify ? 'verify' : 'plan'
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* The write plan: a single-field partial update, its honest previous value
|
||||
* (the table default when the row/slot is absent) and the rollback command.
|
||||
*
|
||||
* @param {{ slug: string, operation: string, value: boolean, currentDoc?: Record<string, unknown> | null }} options
|
||||
*/
|
||||
export const buildOpsGlobalWrite = ({ slug, operation, value, currentDoc = null }) => {
|
||||
const flag = OPS_GLOBAL_TARGETS[slug]?.flags?.[operation]
|
||||
if (flag === undefined) {
|
||||
throw new Error(`operação "${operation}" fora da tabela de ${slug}.`)
|
||||
}
|
||||
|
||||
const rawPrevious = currentDoc?.[flag.field]
|
||||
const previousValue = rawPrevious === undefined ? flag.defaultValue : rawPrevious === true
|
||||
|
||||
return {
|
||||
slug,
|
||||
operation,
|
||||
field: flag.field,
|
||||
previousValue,
|
||||
newValue: value,
|
||||
changed: previousValue !== value,
|
||||
data: { [flag.field]: value },
|
||||
rollbackCommand: formatOpsGlobalCommand(slug, operation, previousValue),
|
||||
}
|
||||
}
|
||||
|
||||
/** The same command with the previous value — the rollback recipe. */
|
||||
export const formatOpsGlobalCommand = (slug, operation, value) =>
|
||||
`pnpm ops:global ${slug} --${operation} ${value ? FLAG_VALUE_ON : FLAG_VALUE_OFF} --apply`
|
||||
|
||||
/**
|
||||
* The manual recovery when the bust fails after the write: no secret value is
|
||||
* ever printed — the env var is referenced by name.
|
||||
*/
|
||||
export const formatOpsGlobalRecoveryCommand = ({ baseUrl, tag }) =>
|
||||
`curl -X POST ${baseUrl}/api/revalidate?tag=${encodeURIComponent(tag)} -H "x-revalidate-secret: $REVALIDATE_SECRET"`
|
||||
|
||||
export const formatOpsGlobalReport = (report) => {
|
||||
const state = `${report.previousValue ? FLAG_VALUE_ON : FLAG_VALUE_OFF} → ${report.newValue ? FLAG_VALUE_ON : FLAG_VALUE_OFF}`
|
||||
const lines = [
|
||||
`[ops:global] modo: ${report.mode} | alvo: ${report.target}`,
|
||||
`[ops:global] ${report.slug}.${report.field}: ${state}${report.changed ? '' : ' (sem mudança)'}`,
|
||||
]
|
||||
|
||||
if (report.mode === 'plan') {
|
||||
lines.push('[ops:global] plano — nenhuma escrita; use --apply para gravar e revalidar.')
|
||||
}
|
||||
if (report.write === 'applied') {
|
||||
lines.push('[ops:global] global atualizado pelo Local API.')
|
||||
}
|
||||
if (report.write === 'skipped') {
|
||||
lines.push(
|
||||
'[ops:global] escrita pulada (valor já era o pedido) — cache revalidado mesmo assim.',
|
||||
)
|
||||
}
|
||||
if (report.revalidation) {
|
||||
lines.push(
|
||||
`[ops:global] revalidação de ${report.revalidation.tag}: ${
|
||||
report.revalidation.ok ? 'ok' : `falhou (${report.revalidation.reason})`
|
||||
}`,
|
||||
)
|
||||
}
|
||||
if (report.mode === 'verify') {
|
||||
lines.push(
|
||||
report.changed
|
||||
? '[ops:global] estado diverge do valor pedido (exit 1).'
|
||||
: '[ops:global] estado confere com o valor pedido.',
|
||||
)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
|
@ -96,6 +96,7 @@ export const SCRIPTS_SPEC_PINNED = [
|
|||
'scripts/lib/markdown-ansi.mjs',
|
||||
'scripts/lib/mediaBinaries.mjs',
|
||||
'scripts/lib/mediaEndpoint.mjs',
|
||||
'scripts/lib/opsGlobal.mjs',
|
||||
'scripts/lib/plansOnlyClosesGuard.mjs',
|
||||
'scripts/lib/playwright-e2e-args.mjs',
|
||||
'scripts/lib/portalTransparenciaEmendas.mjs',
|
||||
|
|
|
|||
258
scripts/ops-global.mjs
Normal file
258
scripts/ops-global.mjs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* C247 — ops CLI to flip an operational public global outside the admin:
|
||||
*
|
||||
* pnpm ops:global photoAlbum --selfie-search on
|
||||
*
|
||||
* Why this exists: the global's `afterChange` revalidates its `unstable_cache`
|
||||
* tag only inside the deployed Next process, so a CLI/DB write left the public
|
||||
* cache frozen and the ops had to click the admin to open/close the selfie
|
||||
* search (C242 friction). This CLI writes through the Local API and busts the
|
||||
* tag on the live server through the allowlisted `POST /api/revalidate`.
|
||||
*
|
||||
* Modes:
|
||||
* (sem flag) plan/dry-run (default) — previous → new; no write
|
||||
* --apply writes the single field and busts the tag;
|
||||
* requires OPS_GLOBAL_CONFIRM=1 outside local
|
||||
* dev and TEQO_ENV matching the exact database
|
||||
* --verify rereads the flag; exit 1 when it diverges
|
||||
*
|
||||
* The write surface is the closed table in `scripts/lib/opsGlobal.mjs` — a
|
||||
* flag only becomes writable by editing it there. The receipt records the
|
||||
* previous/new value and the rollback (the same command with the previous
|
||||
* value); the endpoint secret never enters the receipt or the log.
|
||||
*
|
||||
* Runbook recipe: `docs/ops/teqo-1313-deploy.md` (C247).
|
||||
*/
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { isArchivePhotoRemovalChannelUrl } from '../src/lib/archivePhotoCatalog.ts'
|
||||
import { getGlobalCacheTag } from '../src/utilities/globals.ts'
|
||||
import { resolveRevalidateTag } from '../src/utilities/revalidateRequest.ts'
|
||||
|
||||
import {
|
||||
TEQO_ENV_DATABASE_BY_ENV,
|
||||
assertEnvironmentDatabaseTarget,
|
||||
assertWriteConfirm,
|
||||
databaseTarget,
|
||||
dieWithLabel,
|
||||
loadCliEnv,
|
||||
writeRepoFile,
|
||||
} from './lib/cli.mjs'
|
||||
import {
|
||||
OPS_GLOBAL_DEFAULT_OUT_DIR,
|
||||
OPS_GLOBAL_WRITE_CONFIRM_FLAG,
|
||||
buildOpsGlobalWrite,
|
||||
formatOpsGlobalCommand,
|
||||
formatOpsGlobalOperations,
|
||||
formatOpsGlobalRecoveryCommand,
|
||||
formatOpsGlobalReport,
|
||||
opsGlobalReportStamp,
|
||||
parseOpsGlobalCliArgs,
|
||||
} from './lib/opsGlobal.mjs'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const die = dieWithLabel('ops:global')
|
||||
|
||||
const BUST_TIMEOUT_MS = 15_000
|
||||
|
||||
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
|
||||
.map(([environment, database]) => `${environment} (${database})`)
|
||||
.join(' | ')
|
||||
|
||||
const HELP = `
|
||||
Uso: pnpm ops:global <global> --<flag> <on|off> [opções]
|
||||
|
||||
Liga/desliga uma flag operacional de global público pela CLI, gravando pelo
|
||||
Local API e revalidando a tag no servidor vivo (sem restart e sem admin).
|
||||
|
||||
Modos:
|
||||
(sem flag) plano/dry-run — valor anterior → novo; sem escrita
|
||||
--apply grava e revalida (exige ${OPS_GLOBAL_WRITE_CONFIRM_FLAG}=1 fora do dev local)
|
||||
--verify relê a flag; exit 1 se divergir do valor pedido
|
||||
|
||||
Operações (tabela fechada):
|
||||
${formatOpsGlobalOperations().join('\n')}
|
||||
|
||||
Ambiente:
|
||||
DATABASE_URL alvo (obrigatório em todos os modos)
|
||||
NEXT_PUBLIC_SITE_URL / REVALIDATE_SECRET exigidos no --apply (alvo da revalidação)
|
||||
|
||||
--apply em alvo não-local/produção exige ${OPS_GLOBAL_WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines}
|
||||
casando o banco exato. Plano e verify são read-only.
|
||||
`
|
||||
|
||||
const writeReport = async (report) => {
|
||||
const stamp = opsGlobalReportStamp(report.runAt)
|
||||
const relativePath = join(OPS_GLOBAL_DEFAULT_OUT_DIR, 'reports', `ops-global-${stamp}.json`)
|
||||
await writeRepoFile({
|
||||
label: 'ops:global',
|
||||
root: process.cwd(),
|
||||
relativePath,
|
||||
body: JSON.stringify(report, null, 2),
|
||||
})
|
||||
return join(process.cwd(), relativePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bust the global's tag on the live server; a failure is returned, never
|
||||
* silent. The same shape as the Instagram import helper — except here the
|
||||
* caller fails the run on `ok: false`, because the bust is the outcome.
|
||||
*/
|
||||
const revalidateGlobalOnServer = async (tag) => {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/+$/, '')
|
||||
const secret = process.env.REVALIDATE_SECRET?.trim()
|
||||
if (!baseUrl || !secret) {
|
||||
return { ok: false, tag, reason: 'NEXT_PUBLIC_SITE_URL/REVALIDATE_SECRET ausentes' }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/revalidate?tag=${encodeURIComponent(tag)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-revalidate-secret': secret },
|
||||
signal: AbortSignal.timeout(BUST_TIMEOUT_MS),
|
||||
})
|
||||
return response.ok
|
||||
? { ok: true, tag, reason: null }
|
||||
: { ok: false, tag, reason: `HTTP ${response.status}` }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
tag,
|
||||
reason: error instanceof Error && error.message !== '' ? error.message : 'falha de rede',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const printReport = (report, reportPath) => {
|
||||
for (const line of formatOpsGlobalReport(report)) console.log(line)
|
||||
console.log(`\n[ops:global] recibo JSON: ${reportPath}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseOpsGlobalCliArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
console.log(HELP)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
|
||||
|
||||
const target = databaseTarget()
|
||||
const tag = getGlobalCacheTag(options.slug)
|
||||
console.log(`[ops:global] alvo: ${target} | modo: ${options.mode}`)
|
||||
|
||||
if (options.mode === 'apply') {
|
||||
assertWriteConfirm({
|
||||
label: 'ops:global',
|
||||
flag: OPS_GLOBAL_WRITE_CONFIRM_FLAG,
|
||||
command: formatOpsGlobalCommand(options.slug, options.operation, options.value),
|
||||
})
|
||||
assertEnvironmentDatabaseTarget()
|
||||
if (!process.env.NEXT_PUBLIC_SITE_URL?.trim() || !process.env.REVALIDATE_SECRET?.trim()) {
|
||||
die(
|
||||
'NEXT_PUBLIC_SITE_URL/REVALIDATE_SECRET ausentes — sem eles não há como revalidar a tag no servidor vivo. Rode no ambiente do alvo (env file do stack).',
|
||||
)
|
||||
}
|
||||
// Fail-closed BEFORE the write: a tag outside the endpoint allowlist would
|
||||
// only surface after the global had already changed.
|
||||
const allowed = resolveRevalidateTag(tag, null)
|
||||
if (!allowed.ok) {
|
||||
die(`tag "${tag}" fora da allowlist do /api/revalidate — ${allowed.error}.`)
|
||||
}
|
||||
}
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const payload = await getPayload({ config })
|
||||
const startedAt = Date.now()
|
||||
|
||||
const currentDoc = await payload.findGlobal({
|
||||
slug: options.slug,
|
||||
depth: 0,
|
||||
// Intentional bypass: the ops CLI is a trusted operator with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const write = buildOpsGlobalWrite({
|
||||
slug: options.slug,
|
||||
operation: options.operation,
|
||||
value: options.value,
|
||||
currentDoc,
|
||||
})
|
||||
|
||||
const baseReport = {
|
||||
runAt: new Date().toISOString(),
|
||||
mode: options.mode,
|
||||
target,
|
||||
slug: write.slug,
|
||||
operation: write.operation,
|
||||
field: write.field,
|
||||
previousValue: write.previousValue,
|
||||
newValue: write.newValue,
|
||||
changed: write.changed,
|
||||
rollbackCommand: write.rollbackCommand,
|
||||
}
|
||||
|
||||
if (options.mode !== 'apply') {
|
||||
const report = {
|
||||
...baseReport,
|
||||
write: 'none',
|
||||
revalidation: null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(report)
|
||||
printReport(report, reportPath)
|
||||
process.exit(options.mode === 'verify' && write.changed ? 1 : 0)
|
||||
}
|
||||
|
||||
if (
|
||||
write.changed &&
|
||||
write.field === 'published' &&
|
||||
write.newValue === true &&
|
||||
!isArchivePhotoRemovalChannelUrl(currentDoc?.removalChannelUrl)
|
||||
) {
|
||||
die(
|
||||
'canal de remoção não configurado no global Álbum de fotos — configure-o pelo admin antes de publicar (o hook fail-closed recusaria a escrita).',
|
||||
)
|
||||
}
|
||||
|
||||
if (write.changed) {
|
||||
await payload.updateGlobal({
|
||||
slug: options.slug,
|
||||
data: write.data,
|
||||
depth: 0,
|
||||
// Intentional bypass: same trusted operator.
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
const revalidation = await revalidateGlobalOnServer(tag)
|
||||
const report = {
|
||||
...baseReport,
|
||||
write: write.changed ? 'applied' : 'skipped',
|
||||
revalidation,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(report)
|
||||
printReport(report, reportPath)
|
||||
|
||||
if (!revalidation.ok) {
|
||||
die(
|
||||
`global ${write.changed ? 'atualizado' : 'já estava no valor pedido'}, mas a revalidação da tag falhou (${revalidation.reason}).\n` +
|
||||
`Recupere manualmente:\n ${formatOpsGlobalRecoveryCommand({
|
||||
baseUrl: process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/+$/, '') ?? '',
|
||||
tag,
|
||||
})}\n` +
|
||||
`Rollback: ${write.rollbackCommand}\n` +
|
||||
`Recibo: ${reportPath}`,
|
||||
)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
die(error?.message || String(error))
|
||||
})
|
||||
|
|
@ -7,6 +7,15 @@ export const REVALIDATE_POSTS_TAG = 'posts' as const
|
|||
|
||||
export const REVALIDATE_PRIVACY_POLICY_CACHE_TAG = getGlobalCacheTag('privacy-policy')
|
||||
|
||||
/**
|
||||
* C247 — the `photoAlbum` global's tag (`/fotos`, `/fotos/encontre` and the
|
||||
* home section cache under it). The ops CLI (`pnpm ops:global`) writes the
|
||||
* global outside the deployed runtime, where the `afterChange` hook's
|
||||
* `revalidateTag` cannot reach the live cache: it busts the tag through this
|
||||
* endpoint. Derived from the same owner the global hook busts.
|
||||
*/
|
||||
export const REVALIDATE_PHOTO_ALBUM_CACHE_TAG = getGlobalCacheTag('photoAlbum')
|
||||
|
||||
/**
|
||||
* C233 — the archive photos listing tag the public album (`/fotos`) caches
|
||||
* under. Derived from the same owner the collection hook busts, so the runbook
|
||||
|
|
@ -32,6 +41,7 @@ export const REVALIDATE_SOCIAL_FEED_TAG = 'social-feed' as const
|
|||
const ALLOWED_REVALIDATE_TAGS = [
|
||||
REVALIDATE_POSTS_TAG,
|
||||
REVALIDATE_PRIVACY_POLICY_CACHE_TAG,
|
||||
REVALIDATE_PHOTO_ALBUM_CACHE_TAG,
|
||||
ELECTION_TSE_CACHE_TAG,
|
||||
MUNICIPALITY_CATALOG_CACHE_TAG,
|
||||
REVALIDATE_SOCIAL_FEED_TAG,
|
||||
|
|
|
|||
|
|
@ -216,6 +216,53 @@ describe('public photo album reads (C233)', () => {
|
|||
expect(await getApprovedArchivePhotoById(photo.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the single-field global update safe — the ops CLI path (C247)', async () => {
|
||||
// `pnpm ops:global` writes ONE field at a time. Payload merges the
|
||||
// original doc into the global `beforeValidate`, so the fail-closed channel
|
||||
// guard still sees the stored value and the sibling fields survive.
|
||||
await setChannel(CHANNEL)
|
||||
|
||||
const updated = await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { selfieSearchEnabled: true },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
)
|
||||
expect(updated.selfieSearchEnabled).toBe(true)
|
||||
expect(updated.published).toBe(true)
|
||||
expect(updated.removalChannelUrl).toBe(CHANNEL)
|
||||
|
||||
// The partial write alone cannot open the album without a stored channel:
|
||||
// the same fail-closed guard refuses it.
|
||||
await closeAlbumAndClearChannel()
|
||||
await expect(
|
||||
withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: true },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow()
|
||||
const closed = await payload.findGlobal({
|
||||
slug: 'photoAlbum',
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(closed.published).toBe(false)
|
||||
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { selfieSearchEnabled: false },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
)
|
||||
// Restore the beforeAll state the sibling specs rely on.
|
||||
await setChannel(CHANNEL)
|
||||
})
|
||||
|
||||
it('snapshots the município name/slug on the row so the public read never touches the campaign-only collection', async () => {
|
||||
const photo = await createPhoto()
|
||||
const municipality = await payload.find({
|
||||
|
|
|
|||
228
tests/unit/opsGlobal.unit.spec.ts
Normal file
228
tests/unit/opsGlobal.unit.spec.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
OPS_GLOBAL_TARGETS,
|
||||
buildOpsGlobalWrite,
|
||||
formatOpsGlobalOperations,
|
||||
formatOpsGlobalRecoveryCommand,
|
||||
formatOpsGlobalReport,
|
||||
opsGlobalReportStamp,
|
||||
parseOpsGlobalCliArgs,
|
||||
} from '../../scripts/lib/opsGlobal.mjs'
|
||||
|
||||
// C247 — the pure parser/plan of `pnpm ops:global`: the closed write surface,
|
||||
// the dry-run default, the previous→new plan with rollback and the receipt
|
||||
// helpers (the recovery recipe never carries the secret value).
|
||||
|
||||
describe('OPS_GLOBAL_TARGETS', () => {
|
||||
it('is a closed table: photoAlbum with the two operational flags only', () => {
|
||||
expect(Object.keys(OPS_GLOBAL_TARGETS)).toEqual(['photoAlbum'])
|
||||
expect(Object.keys(OPS_GLOBAL_TARGETS.photoAlbum.flags)).toEqual(['selfie-search', 'published'])
|
||||
})
|
||||
|
||||
it('generates the HELP operation block from the table', () => {
|
||||
const lines = formatOpsGlobalOperations().join('\n')
|
||||
expect(lines).toContain('photoAlbum --selfie-search <on|off>')
|
||||
expect(lines).toContain('photoAlbum --published <on|off>')
|
||||
expect(lines).toContain('Busca por selfie')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseOpsGlobalCliArgs', () => {
|
||||
it('defaults to the plan mode and parses the operation', () => {
|
||||
expect(parseOpsGlobalCliArgs(['photoAlbum', '--selfie-search', 'on'])).toEqual({
|
||||
slug: 'photoAlbum',
|
||||
operation: 'selfie-search',
|
||||
value: true,
|
||||
mode: 'plan',
|
||||
help: false,
|
||||
})
|
||||
expect(parseOpsGlobalCliArgs(['photoAlbum', '--published', 'off', '--apply'])).toEqual({
|
||||
slug: 'photoAlbum',
|
||||
operation: 'published',
|
||||
value: false,
|
||||
mode: 'apply',
|
||||
help: false,
|
||||
})
|
||||
expect(
|
||||
parseOpsGlobalCliArgs(['--verify', 'photoAlbum', '--selfie-search', 'off']),
|
||||
).toMatchObject({
|
||||
operation: 'selfie-search',
|
||||
value: false,
|
||||
mode: 'verify',
|
||||
})
|
||||
expect(parseOpsGlobalCliArgs(['--help']).help).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed on unknown slug/flag/value, missing value and mode conflicts', () => {
|
||||
expect(() => parseOpsGlobalCliArgs([])).toThrow(/informe o global/)
|
||||
expect(() => parseOpsGlobalCliArgs(['homePage', '--selfie-search', 'on'])).toThrow(
|
||||
/fora da tabela/,
|
||||
)
|
||||
expect(() => parseOpsGlobalCliArgs(['photoAlbum', '--nope', 'on'])).toThrow(/fora da tabela/)
|
||||
expect(() => parseOpsGlobalCliArgs(['photoAlbum', '--selfie-search', 'maybe'])).toThrow(
|
||||
/valor inválido/,
|
||||
)
|
||||
expect(() => parseOpsGlobalCliArgs(['photoAlbum', '--selfie-search'])).toThrow(/faltou valor/)
|
||||
expect(() => parseOpsGlobalCliArgs(['photoAlbum', '--selfie-search', 'on', 'extra'])).toThrow(
|
||||
/posicional desconhecido/,
|
||||
)
|
||||
expect(() =>
|
||||
parseOpsGlobalCliArgs(['photoAlbum', '--selfie-search', 'on', '--published', 'off']),
|
||||
).toThrow(/mais de uma operação/)
|
||||
expect(() => parseOpsGlobalCliArgs(['photoAlbum'])).toThrow(/informe a operação/)
|
||||
expect(() =>
|
||||
parseOpsGlobalCliArgs(['photoAlbum', '--selfie-search', 'on', '--apply', '--verify']),
|
||||
).toThrow(/mutuamente exclusivos/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildOpsGlobalWrite', () => {
|
||||
it('plans a single-field write with the previous value and the rollback command', () => {
|
||||
const fromAbsent = buildOpsGlobalWrite({
|
||||
slug: 'photoAlbum',
|
||||
operation: 'selfie-search',
|
||||
value: true,
|
||||
currentDoc: {},
|
||||
})
|
||||
expect(fromAbsent).toMatchObject({
|
||||
field: 'selfieSearchEnabled',
|
||||
previousValue: false,
|
||||
newValue: true,
|
||||
changed: true,
|
||||
data: { selfieSearchEnabled: true },
|
||||
rollbackCommand: 'pnpm ops:global photoAlbum --selfie-search off --apply',
|
||||
})
|
||||
|
||||
const fromFalse = buildOpsGlobalWrite({
|
||||
slug: 'photoAlbum',
|
||||
operation: 'published',
|
||||
value: true,
|
||||
currentDoc: { published: false },
|
||||
})
|
||||
expect(fromFalse).toMatchObject({
|
||||
previousValue: false,
|
||||
changed: true,
|
||||
rollbackCommand: 'pnpm ops:global photoAlbum --published off --apply',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports no change when the flag already holds the requested value', () => {
|
||||
const write = buildOpsGlobalWrite({
|
||||
slug: 'photoAlbum',
|
||||
operation: 'published',
|
||||
value: true,
|
||||
currentDoc: { published: true },
|
||||
})
|
||||
expect(write.changed).toBe(false)
|
||||
expect(write.data).toEqual({ published: true })
|
||||
})
|
||||
|
||||
it('falls back to the table default when the row/slot is absent', () => {
|
||||
// `published` defaults to true in the global; an absent row is an open album.
|
||||
const write = buildOpsGlobalWrite({
|
||||
slug: 'photoAlbum',
|
||||
operation: 'published',
|
||||
value: true,
|
||||
currentDoc: null,
|
||||
})
|
||||
expect(write.previousValue).toBe(true)
|
||||
expect(write.changed).toBe(false)
|
||||
})
|
||||
|
||||
it('rolls back to on when the previous value was true, and refuses unknown operations', () => {
|
||||
const write = buildOpsGlobalWrite({
|
||||
slug: 'photoAlbum',
|
||||
operation: 'published',
|
||||
value: false,
|
||||
currentDoc: { published: true },
|
||||
})
|
||||
expect(write.rollbackCommand).toBe('pnpm ops:global photoAlbum --published on --apply')
|
||||
|
||||
expect(() =>
|
||||
buildOpsGlobalWrite({
|
||||
slug: 'photoAlbum',
|
||||
operation: 'nope',
|
||||
value: true,
|
||||
currentDoc: {},
|
||||
}),
|
||||
).toThrow(/fora da tabela/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('receipt helpers', () => {
|
||||
it('stamps the receipt name like the other CLIs', () => {
|
||||
expect(opsGlobalReportStamp('2026-10-01T04:09:04.123Z')).toBe('2026-10-01T04-09-04-123Z')
|
||||
})
|
||||
|
||||
it('builds the recovery command without ever printing the secret value', () => {
|
||||
const command = formatOpsGlobalRecoveryCommand({
|
||||
baseUrl: 'https://jorgesolla1313.com.br',
|
||||
tag: 'global_photoAlbum',
|
||||
})
|
||||
expect(command).toContain('/api/revalidate?tag=global_photoAlbum')
|
||||
expect(command).toContain('x-revalidate-secret: $REVALIDATE_SECRET')
|
||||
})
|
||||
|
||||
it('prints the plan line and the divergence verdict', () => {
|
||||
const plan = formatOpsGlobalReport({
|
||||
mode: 'plan',
|
||||
target: 'host/db',
|
||||
slug: 'photoAlbum',
|
||||
field: 'selfieSearchEnabled',
|
||||
previousValue: false,
|
||||
newValue: true,
|
||||
changed: true,
|
||||
write: 'none',
|
||||
revalidation: null,
|
||||
}).join('\n')
|
||||
expect(plan).toContain('modo: plan')
|
||||
expect(plan).toContain('photoAlbum.selfieSearchEnabled: off → on')
|
||||
expect(plan).toContain('use --apply')
|
||||
|
||||
const verify = formatOpsGlobalReport({
|
||||
mode: 'verify',
|
||||
target: 'host/db',
|
||||
slug: 'photoAlbum',
|
||||
field: 'selfieSearchEnabled',
|
||||
previousValue: false,
|
||||
newValue: true,
|
||||
changed: true,
|
||||
write: 'none',
|
||||
revalidation: null,
|
||||
}).join('\n')
|
||||
expect(verify).toContain('diverge do valor pedido')
|
||||
})
|
||||
|
||||
it('distinguishes the applied and skipped writes and the bust outcome', () => {
|
||||
const applied = formatOpsGlobalReport({
|
||||
mode: 'apply',
|
||||
target: 'host/db',
|
||||
slug: 'photoAlbum',
|
||||
field: 'selfieSearchEnabled',
|
||||
previousValue: false,
|
||||
newValue: true,
|
||||
changed: true,
|
||||
write: 'applied',
|
||||
revalidation: { ok: true, tag: 'global_photoAlbum', reason: null },
|
||||
}).join('\n')
|
||||
expect(applied).toContain('global atualizado pelo Local API.')
|
||||
expect(applied).toContain('revalidação de global_photoAlbum: ok')
|
||||
|
||||
const skipped = formatOpsGlobalReport({
|
||||
mode: 'apply',
|
||||
target: 'host/db',
|
||||
slug: 'photoAlbum',
|
||||
field: 'selfieSearchEnabled',
|
||||
previousValue: true,
|
||||
newValue: true,
|
||||
changed: false,
|
||||
write: 'skipped',
|
||||
revalidation: { ok: false, tag: 'global_photoAlbum', reason: 'HTTP 401' },
|
||||
}).join('\n')
|
||||
expect(skipped).toContain('escrita pulada')
|
||||
expect(skipped).toContain('falhou (HTTP 401)')
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,7 @@ import { getGlobalCacheTag } from '@/utilities/globals'
|
|||
import { MUNICIPALITY_CATALOG_CACHE_TAG } from '@/utilities/municipality/municipalityCatalogCache'
|
||||
import {
|
||||
REVALIDATE_CONTENT_PIECES_TAG,
|
||||
REVALIDATE_PHOTO_ALBUM_CACHE_TAG,
|
||||
REVALIDATE_POSTS_TAG,
|
||||
REVALIDATE_PRIVACY_POLICY_CACHE_TAG,
|
||||
resolveRevalidateTag,
|
||||
|
|
@ -37,6 +38,11 @@ describe('resolveRevalidateTag', () => {
|
|||
ok: true,
|
||||
tag: REVALIDATE_CONTENT_PIECES_TAG,
|
||||
})
|
||||
// C247 — the ops CLI busts the photo album global it wrote outside Next.
|
||||
expect(resolveRevalidateTag(REVALIDATE_PHOTO_ALBUM_CACHE_TAG, null)).toEqual({
|
||||
ok: true,
|
||||
tag: REVALIDATE_PHOTO_ALBUM_CACHE_TAG,
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers query tag over body tag', () => {
|
||||
|
|
@ -59,4 +65,8 @@ describe('resolveRevalidateTag', () => {
|
|||
it('maps privacy-policy global slug to the shared cache tag helper', () => {
|
||||
expect(REVALIDATE_PRIVACY_POLICY_CACHE_TAG).toBe(getGlobalCacheTag('privacy-policy'))
|
||||
})
|
||||
|
||||
it('maps the photo album global slug to the shared cache tag helper (C247)', () => {
|
||||
expect(REVALIDATE_PHOTO_ALBUM_CACHE_TAG).toBe(getGlobalCacheTag('photoAlbum'))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue