diff --git a/.gitignore b/.gitignore index df6db5a0..286cbaeb 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/plans/ops-toggle-globais-publicos-impl.md b/docs/plans/ops-toggle-globais-publicos-impl.md new file mode 100644 index 00000000..2f167205 --- /dev/null +++ b/docs/plans/ops-toggle-globais-publicos-impl.md @@ -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
--selfie-search on [--apply|--verify]"] --> PLAN["tabela fechada (operação→campo)
plan default"] + PLAN --> GUARD["guardas: OPS_GLOBAL_CONFIRM
TEQO_ENV↔banco · preflight do bust"] + GUARD --> WRITE["payload.updateGlobal
campo único · overrideAccess"] + WRITE --> REC["recibo JSON: previous→new
+ rollback (sem segredo)"] + end + WRITE --> POST["POST /api/revalidate?tag=global_photoAlbum
x-revalidate-secret"] + POST --> EP["route.ts + revalidateRequest.ts
allowlist fechada"] + EP --> BUST["revalidateTag('global_photoAlbum')
no processo Next vivo"] + BUST --> SURF["/fotos · /fotos/encontre · home
≤60 s · sem restart"] + POST -. falha .-> ERR["erro alto + curl de recuperação
(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 =` 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=` 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-.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. diff --git a/package.json b/package.json index 7b89765b..1c80bfb2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lib/opsGlobal.mjs b/scripts/lib/opsGlobal.mjs new file mode 100644 index 00000000..2f5a2ea9 --- /dev/null +++ b/scripts/lib/opsGlobal.mjs @@ -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 -- [--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} ${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 | 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 +} diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 12d49b1b..b9f1fa14 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -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', diff --git a/scripts/ops-global.mjs b/scripts/ops-global.mjs new file mode 100644 index 00000000..d7eba52c --- /dev/null +++ b/scripts/ops-global.mjs @@ -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 -- [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)) +}) diff --git a/src/utilities/revalidateRequest.ts b/src/utilities/revalidateRequest.ts index 3178faec..595d0c19 100644 --- a/src/utilities/revalidateRequest.ts +++ b/src/utilities/revalidateRequest.ts @@ -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, diff --git a/tests/int/archivePhotoPublicRead.int.spec.ts b/tests/int/archivePhotoPublicRead.int.spec.ts index 36afe422..4ac1d73c 100644 --- a/tests/int/archivePhotoPublicRead.int.spec.ts +++ b/tests/int/archivePhotoPublicRead.int.spec.ts @@ -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({ diff --git a/tests/unit/opsGlobal.unit.spec.ts b/tests/unit/opsGlobal.unit.spec.ts new file mode 100644 index 00000000..847f0370 --- /dev/null +++ b/tests/unit/opsGlobal.unit.spec.ts @@ -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 ') + expect(lines).toContain('photoAlbum --published ') + 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)') + }) +}) diff --git a/tests/unit/revalidateRequest.unit.spec.ts b/tests/unit/revalidateRequest.unit.spec.ts index 208d3502..bb0588ac 100644 --- a/tests/unit/revalidateRequest.unit.spec.ts +++ b/tests/unit/revalidateRequest.unit.spec.ts @@ -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')) + }) })