diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 76bb9dae..acc0eebd 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -1,3 +1,5 @@ +**Recently resolved (2026-08-11):** **D10** (Issue #617) — auto-cura de leftovers de consent/supporter + fim do shared-write latente no lease de consent (pós-D9). **F1 (self-heal):** `purgeOrphanedConsentRenames` (3 nomes explícitos de renames temporários — `temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) roda sob lease **exclusivo**, em **autocommit** (delete na transação do lease seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada — observado na primeira implementação), nos 3 pontos que tocam a key canônica (`withMutableConsentFixture` antes do snapshot, setup do `withMissingInviteConsentFixture`, `ensureLeasedConsent`) — uma run abortada no meio de um rename de teste não envenena mais o run seguinte (UNIQUE `consent_key_idx` auto-sustentado). **F1b:** `purgeMunicipalityResidue` também deleta os **contatos** que supporters residue órfãos deixaram (reference-check em leadership/signature/subscription/supporter pós-delete — mesmo critério do `removeSupporterData`, reimplementado no helper). **F2 (refutação por medição + design final):** a hipótese "shared-writer = ABBA latente" caiu sob carga — exclusivo-durante-a-operation → fila de 17 waiters e cascata de 15s (6 workers); operation sem lease → FK `consent_id` (cleanup de fixture apagava linha canônica adotada); o ABBA não é construtível hoje (escritas disjuntas por teste; Onda0 em ordem fixa de 3 keys). As flakes reais do baseline (3/4 runs) eram: consumidores observando a ausência commitada da janela missing-consent (janela D9 era **shared**), down-test da Onda0 deletando as 4 keys **não-atomicamente**, e provision re-textando no meio de asserções de preview. **Fix:** janela do `withMissingInviteConsentFixture` → **exclusiva** (bloqueia consumidores shared durante a ausência; fila limitada ~200–500ms — a medição D9 do "15s tight" era o regime ONDA0-exclusivo, inexistente hoje); down-test da Onda0 **atômico** (delete+count+restore numa transação — FK insert espera o commit via speculative insertion); provision da Onda0 com leases **exclusivos** aninhados (ordem fixa CI→SR→VI); consumidores continuam shared. **Review /simplify (2 reviewers):** P1-1 — os renames vivos de `campaignInvite`/`campaignFixtures` migraram para chaves privadas (`consent-vacated-`/`owned-consent-vacated-`) — nenhum teste vivo produz os 3 nomes do purge; comentários "Onda0 shared" corrigidos; `withExclusiveTestDatabaseLease` colapsado (era cópia do privado); rollback no down-test quando a sessão falta; cleanup no finally nos testes F1 (auto-curáveis — um run falho do próprio teste não polui). Testes novos: contrato da janela exclusiva (waiter SHARED bloqueado), purge em ensure/setup, self-heal do canônico (repro do run abortado, 2 fixtures), F1b. **Contraprova:** 7 arquivos da key × 6 workers → **12/12 verdes** (baseline 1/4; designs intermediários: cascata 15s / FK); suíte int completa 83 arquivos/701 testes; unit 1938; e2e paridade com baseline (10 falhas pré-existentes de chat IA/webauthn/mic reproduzem no código original). Sem migration/access/Consent/`src/`. Intenção [`escala-dry-pos-d9.md`](plans/escala-dry-pos-d9.md); impl [`escala-dry-pos-d9-impl.md`](plans/escala-dry-pos-d9-impl.md). + **Recently resolved (2026-08-10):** **Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc).** O `requestPermission` do card de perfil rodava **dentro de `startTransition(async …)`** — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome **auto-nega silenciosamente** (resolve `'denied'` sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e **`Notification.requestPermission()` passa a ser chamado sincronamente no clique** (antes de qualquer `await`/transição); mensagens distintas para `denied` (permissão bloqueada — reativar nas configurações do site; um `denied` não pode ser re-perguntado por nenhuma API) vs `default` (prompt não exibido). **Opt-in automático** (pedido do usuário): novo `CampaignPushOptInToast` no shell `(app)` (padrão `InstallPwaToast`/`BiometricEnrollmentToast`), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando `Notification.permission === 'default'` (nunca `denied`), sem inscrição ativa no device, consentimento `campanha-notificacoes-push` publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em `localStorage` (chave `campaign-push-opt-in-dismissed`); ativação/desativação/leitura de inscrição extraídas para `src/utilities/campaignPushClient.ts` (compartilhado com o card — don't twin; registrado no pin de top-level com razão). **Diagnóstico de prod (read-only, `db:check:push-chain`):** consentimento `campanha-notificacoes-push` **já publicado** (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — **único elo quebrado: 0 inscrições push** (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree `work/3`. **Recently resolved (2026-08-10):** **OPS37** (Issue #605) — o container Postgres compartilhado **não é mais recriado** por um worktree vizinho: `pnpm db:start` virou `scripts/db-start.mjs` (CLI) sobre `scripts/lib/db-start.mjs` — preflight "container `teqo-postgres-1` Up + `healthy`/`starting` → **no-op** (compose nem roda); senão, `compose -p teqo up -d --wait --wait-timeout 120 postgres` como antes. **Causa raiz provada por medição:** o bind mount **relativo** `./docker/postgres/init` do compose resolve um path absoluto por worktree → `config_hash` diferente por worktree (OPS37 `39e89956…` / B195 `cf3cb522…` / C101 `917e11bb…`; diff do `config` = só o `source:` do bind) → qualquer `compose up` cru de outro worktree fazia `replace` e matava dev+e2e de todos (`terminating connection due to administrator command`) — o incidente se repetiu **ao vivo durante o gate**: o worktree `work/4` recriou o container no meio da suíte int (17 testes mortos; os 3 arquivos re-rodaram verdes e a suíte completa 691/691 passou em seguida). **Verificação do aceite:** `StartedAt` inalterado após `db:start` repetido, inclusive rodado de **outro worktree** (D9, hash diferente) — o caso que antes recriava. Recriação legítima (bump de imagem/config) segue por `pnpm db:start --force-recreate` (explícito; não testado live para não matar a run e2e do D9 em voo — flag é pass-through de 1 arg). `worktree.mjs` provisionamento delega para a mesma lib (mesmo risco no `worktree next`; `dockerComposeUp`/`SHARED_COMPOSE_PROJECT` órfãos removidos). Skip deliberado em `starting` (container que um peer acabou de criar): **espera** o boot ficar `healthy` (60s, polling 2s) em vez de recriar — e cai no compose up se travou; **não** skip em `unhealthy`/sem healthcheck (volta à config atual exatamente uma vez); `db:stop`/`db:doctor`/compose file/CI intactos. Unit novos (`dbStart.unit.spec.ts`, 17: matrix running×health + parse do inspect + wrapper mockado — skip não chama compose, argv do up/force-recreate, espera de `starting`). Sem migration/access/Consent/UI. Intenção [`container-postgres-compartilhado-compose-replace.md`](plans/container-postgres-compartilhado-compose-replace.md); impl [`container-postgres-compartilhado-compose-replace-impl.md`](plans/container-postgres-compartilhado-compose-replace-impl.md). diff --git a/docs/plans/escala-dry-pos-d9-impl.md b/docs/plans/escala-dry-pos-d9-impl.md new file mode 100644 index 00000000..9981af2f --- /dev/null +++ b/docs/plans/escala-dry-pos-d9-impl.md @@ -0,0 +1,112 @@ +# Impl: Auto-cura de leftovers de consent/supporter + ABBA do shared-write do lease de consent (pós-D9) + +Status: em execução (F1 + F2 implementados; review /simplify aplicado; gates verdes; PR pendente) +Atualizado em: 2026-08-11 +Issue: #617 +Intenção: docs/plans/escala-dry-pos-d9.md +Appetite restante: herdado (só testes/helpers; zero `src/`; zero migration) + +## Leitura da intenção + +- **Outcome:** (F1) uma run abortada no meio de um fixture de rename/cleanup nunca mais envenena o run seguinte — renames órfãos de consent e contatos/supporters órfãos são reconhecidos e removidos deterministicamente; (F2) o ABBA latente (lease shared de consent + operation escritora) deixa de existir — cascata de timeouts não reproduz sob 6 workers + carga. +- **O que NÃO negociar:** mecanismo de lease (advisory lock) é canônico; orçamento de 15s do vitest fora; 4ª key da Onda0 sem lease fora; nada de `src/` nem migration. +- **O que reavaliar:** a hipótese "lista vs padrão vs re-snapshot por id" da F1 (o re-snapshot é impossível — ver decisões); o alvo exato da F2 (compartilhado entre `withLeasedConsent`/`withInviteConsent`/janela do `withMissingInviteConsentFixture`/`campaignSupporter:95` — o contrato de leitura/escrita cobre todos). + +## Caracterização (auditoria feita no código) + +**F1 — mecanismo exato da poluição auto-sustentada.** Os testes renomeiam a linha canônica para `chave-renomeada` (campaignInvite.int.spec.ts:1615), `chave-temporariamente-ausente` (campaignInvite.int.spec.ts:1679), `temporarily-renamed-consent` (testDatabaseLease.int.spec.ts:311) e `temporarily-unkeyed-` (campaignFixtures.int.spec.ts:254), sempre dentro do lease **exclusivo** do fixture (renomeia + restaura no mesmo holder). Se o run morre entre o commit do rename e o restore, a linha órfã persiste com a key temporária e a key canônica fica ausente. No run seguinte o fixture snapshotta "ausente", cria linha nova, e o teste renomeia para a MESMA key temporária → UNIQUE `consent_key_idx` (`Valor deve ser único`) → poluição auto-sustentada. Garantia de segurança do purge: qualquer renomeador vivo está segurando o lease exclusivo da key — quem faz o purge também o segura, então nenhuma linha temporária viva pode existir durante o purge (renomeadores em fila só agem após o release, e partem do snapshot pós-purge). + +**F2 — ciclo exato do ABBA.** `withLeasedConsent` (e `withInviteConsent`) segura o lease **shared** durante a `operation()`. Operations escritoras (redeem autofill/login → `UPDATE campaignInvite.usedAt`, `UPDATE leadership`, `UPDATE contact`, `CREATE campaignUser`; `createSupporterRecord`/`createLeaderSupporterRecord` → upsert `contact` + `CREATE supporter`) tomam row locks de invite/contato. Dois escritores em spec files diferentes seguram shared (compatível — zero serialização advisory) e intercalam writes sobre linhas sobrepostas → ABBA de row locks. Também: escritor shared + holder exclusivo (`withMutableConsentFixture`, que escreve invites no corpo do teste) → o exclusivo espera o shared enquanto seu corpo segura row locks que o shared-writer quer. Auditoria de ordem canônica e FIFO: a aquisição é sempre "consent-lease primeiro, row locks depois" — o ciclo existe porque DOIS holders seguram locks em ordens opostas (consent↔row); com escritores exclusivos e leitores shared (que não escrevem → não seguram row locks), nenhum ciclo é construtível. + +## Abordagem recomendada + +```mermaid +flowchart LR + F1[F1: purge de renames órfãos] --> H1[purgeOrphanedConsentRenames
lista explícita + padrão temporarily-unkeyed-%] + H1 --> S1[chamado sob o lease exclusivo em
withMutableConsentFixture / withMissingInviteConsentFixture / ensureLeasedConsent] + F1b[F1b: contatos órfãos de residue] --> H2[purgeMunicipalityResidue deleta
contatos sem outras referências] + F2[F2: contrato read/write do withLeasedConsent] --> H3[opção mode:'write' → lease EXCLUSIVO] + H3 --> S2[call sites escritores migram;
leitores continuam shared] + H3 --> S3[janela do withMissingInviteConsentFixture
permanece shared + invariante documentado] +``` + +**Opções consideradas:** A (mode `'write'` explícito por call site, default read) | B (exclusivo default, opt-out read) | C (escritas fora do escopo do lease shared) | D (janela do missing-consent exclusiva) | E (purge por lista explícita + padrão) | F (re-snapshot por id) | G (purge global de consent não-canônico) + +**Recomendação:** + +- **F1 = E**: purge determinístico por lista explícita (`temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) + `key LIKE 'temporarily-unkeyed-%'`, executado sob o lease exclusivo **antes do snapshot** nos três pontos que tocam a key canônica. — porque a lista cobre os 4 nomes reais hoje, o padrão cobre a família `temporarily-unkeyed-` sem risco (nenhuma key estável legítima casa com o padrão) e um único DELETE indexado é barato. +- **F2 = A**: `withLeasedConsent` ganha `{ mode: 'write' }` (lease exclusivo para o ensure + operation num único holder; sem loop de retry — ninguém pode deletar a linha sob exclusivo); default continua `'read'` (shared). Call sites que escrevem migram explicitamente. — porque escritores exclusivos serializam entre si (fim da intercalação de row locks), leitores shared não escrevem → não entram em ciclo, e a medição D9 (janela exclusiva estourou o budget de 15s) não se repete: os escritores de invite são operations curtas (~100–300 ms), não provisions multi-segundo. +- **F2 janela missing-consent = shared, preservada (não D)**: a janela shared bloqueia os exclusivos (ensure/fixtures) sem parar leitores shared — a medição D9 foi explícita ("15s tight under load com janela exclusiva"). A operation da janela escreve, mas é a ÚNICA shared-writer da key hoje (1 arquivo por key: campaignSupporter:95 / testDatabaseLease com keys privadas) e o holder do window não espera por ninguém (exclusivos que esperam a janela não seguram row locks — adquirem após o wait) → ciclo não construtível; vira invariante documentado + comentário. + +**Rejeitadas:** B porque serializaria os LEITORES atrás dos escritores (a fila que estourou o budget na medição D9 — leitores são muitos: `testDatabaseLease`, `campaignFixtures`, previews de `campaignInviteUi`); C porque quebra a garantia "a linha canônica está estável durante a operation" (writers sem lease veriam a ausência commitada de uma janela missing-consent → flake de fail-closed); D porque a medição D9 documentou o budget estourando sob carga com janela exclusiva; F porque o snapshot id morre com o run abortado (impossível re-snapshot por id — só o nome temporário sobrevive); G porque apagaria keys legítimas de fixtures (`fixtures.createConsent` gera keys runID-scoped aleatórias que coexistem). + +## Revisão da F2 por medição (2026-08-11 — mudança material pós-gate) + +O plano aprovado assumia a hipótese da intenção ("shared-writer = ABBA latente") e propunha o contrato `mode: 'write'` → lease **exclusivo** durante a operation. A medição sob carga (7 arquivos da key, 6 workers) **refutou a forma e o mecanismo**: + +1. **Exclusivo durante a operation = cascata.** Fila de 17 waiters advisory + timeouts de 15s em cascata (campaignInvite/campaignInviteUi/testDatabaseLease) — o hold do exclusivo por ~30 testes serializa a key inteira. **Descartado medido.** +2. **Operation sem lease = FK violation.** `withMutableConsentFixture` com snapshot ausente recria a linha canônica; o cleanup do fixture a deleta; um consumidor sem lease que a adotou (redeem gravando `leadership.consent_id`) → `supporter/leadership_consent_id_consent_id_fk`. **Descartado medido.** +3. **O ABBA shared↔row não é construtível hoje:** os consumidores escrevem linhas disjuntas por teste; Onda0 é shared desde D9; nenhum ciclo advisory↔row existe. A cascata D9 era o ONDA0 **exclusivo** (multi-segundo) + fila — regime que já não existe. +4. **As flakes residuais reais (baseline medido: 3/4 runs com 1 falha) eram:** (a) consumidores observando a ausência commitada da janela do missing-consent (janela shared D9 não bloqueia consumidores shared) → 'Consentimento ainda não configurado' em redeems; (b) o down-test da Onda0 deletando as 4 keys estáveis **não-atomicamente** (delete autocommit + restore separado) → FK `consent_id`; (c) o provision da Onda0 reescrevendo textos no meio de asserções de preview (mismatch de `requiresConsent`/`consentData`). + +**Design final (medido 8/8 verdes sob 6 workers; baseline 1/4):** + +- **Consumidores ficam SHARED** (D9) — `withLeasedConsent`/`withInviteConsent` inalterados no modo; o contrato `mode` foi **descartado** (call sites revertidos). +- **Janela do `withMissingInviteConsentFixture` vira EXCLUSIVA** (era shared desde D9): nenhum consumidor observa a ausência commitada — é a mudança que mata a classe (a). O custo é fila limitada (~200–500ms) — a medição D9 do "15s tight" era sob o regime ONDA0-exclusivo, que não existe mais. +- **Down-test da Onda0 atômico** (delete + count + restore numa transação) — mata a classe (b). +- **Provision da Onda0 EXCLUSIVO** (~500ms, fila limitada) — mata a classe (c). +- **Purge F1 sem o padrão `temporarily-unkeyed-%`**: o padrão só era produzido pelo teste `campaignFixtures` ('deletes the stable invite consent'), que agora usa chave privada `owned-consent-vacated-` — a linha canônica renomeada com key no padrão do purge podia ser apagada por purge de outro arquivo no meio da operation (falha NotFound observada). O purge cobre os 3 nomes explícitos. +- **Testes F1 auto-curáveis**: plantas de órfãos sob lease exclusivo + purge no início + cleanup no finally (uma falha do próprio teste não deixa poluição para o run seguinte). + +### Componentes / mudanças (final) + +- **`purgeOrphanedConsentRenames`** (`tests/helpers/testDatabaseLease.ts`): DELETE `WHERE key IN (3 nomes)` via `execute`; autocommit (nunca dentro da transação do lease — delete não-commitado seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada). Chamado sob o lease exclusivo por: (1) `withMutableConsentFixture` antes do snapshot; (2) `withMissingInviteConsentFixture` no setup antes do snapshot; (3) `ensureLeasedConsent` dentro da janela exclusiva antes do find. +- **`withMissingInviteConsentFixture`** (`tests/helpers/testDatabaseLease.ts`): `serializeWindow: true` (default) → janela **exclusiva**; `false` inalterado (teste de ensures concorrentes em key privada). +- **`withExclusiveTestDatabaseLease`** (`tests/helpers/testDatabaseLease.ts`): wrapper novo exportado (mirror do shared). +- **`onda0Provision.int.spec.ts`**: provision com leases exclusivos; down-test com delete+assert+restore numa transação (`beginTransaction` + session db + commit/rollback). +- **`campaignFixtures.ts`** (`purgeMunicipalityResidue`): além do purge de residue existente, deleta os **contatos** que os supporters residue órfãos deixaram (referência-check em leadership/signature/subscription/supporter — mesmo critério do `removeSupporterData`, reimplementado no helper). +- **`campaignFixtures.int.spec.ts`** (teste 'deletes the stable invite consent...'): chave de vacância privada `owned-consent-vacated-` (fora do padrão do purge). +- **Testes novos** (`testDatabaseLease.int.spec.ts`): contrato da janela exclusiva (waiter SHARED bloqueado durante a janela — estende o teste D9); purge em ensure; purge no setup do missing; self-heal do canônico (repro do run abortado, 2 fixtures); **F1b** (`campaignFixtures.int.spec.ts`): purge de contatos órfãos preservando referenciados. +- **Migration:** sem migration (só testes/helpers). +- **Access / Consent:** nenhuma mudança em runtime; chaves e fail-closed intactos. +- **UI:** nenhuma. + +## Fases verificáveis (executadas) + +1. **F2 contrato** — janela exclusiva + contrato testado (waiter SHARED bloqueado durante a janela); consumidores shared preservados. +2. **F1 self-heal** — purge nos 3 pontos; repros: órfão plantado (sob lease), auto-cura no run seguinte (fixture 2×), sem UNIQUE; testes auto-curáveis (cleanup no finally). +3. **F1b residue** — purge de contatos sem referências; repro: plant + purge direto, preservação de referenciado. +4. **Gates + carga** — contraprova: 7 arquivos da key (`campaignInvite`, `campaignInviteUi`, `campaignFixtures`, `testDatabaseLease`, `collectionAccessLockdown`, `campaignSupporter`, `onda0Provision`) com workers=6 → **8/8 runs verdes** (baseline original: 1/4; designs intermediários: cascata de 15s / FK violations); suíte int completa 83 arquivos/701 testes verdes; gates abaixo em andamento. + +## Rabbit holes / Não escopo (engenharia) + +- Mecanismo de lease/advisory lock (canônico, fora — só o modo de aquisição da janela muda). +- Orçamento de 15s do vitest (gatilho: recorrência com pool ativo). +- 4ª key da Onda0 sem lease (gatilho: primeiro escritor do push key). +- Atacar a poluição residual de contatos legados (inertes; fora — o F1b só impede acúmulo novo). +- Re-trabalhar a spec Onda0 ou a Onda0 em si. +- Gap setup→janela do missing-consent (ausência commitada por ~ms entre o commit do setup e a aquisição da janela; consumidor que insira com FK nesse gap ainda pode falhar — classe residual rara, mesma ordem da aceita na D9; gatilho de revisita: recorrência sob carga). + +## Revisão /simplify (2 reviewers independentes, 2026-08-11) — fixes aplicados + +- **P1-1 (ambos reviewers, obrigatório):** o purge deleta os 3 nomes globalmente sob a lease do chamador; os renomeadores vivos de `campaignInvite.int.spec.ts` (linhas 1615/1679) produziam `chave-renomeada`/`chave-temporariamente-ausente` sob a lease invite — um purge sob outra lease key (supporter/privada) podia apagar a linha renomeada viva (a classe exata que a D10 mata). **Fix aplicado:** os dois renames migraram para chave privada `consent-vacated-` (mesmo padrão do `campaignFixtures`); agora NENHUM teste vivo produz os 3 nomes — o único produtor restante é a planta do self-heal (sob a lease invite, imediatamente antes do fixture curador). Invariante documentada no helper. +- **P2-1:** comentários do diff diziam "Onda0 holds shared, never exclusive" — falsos pós-diff (provision é exclusivo). Reescritos. +- **P2-2:** `withExclusiveTestDatabaseLease` era cópia verbatim do privado — colapsado em um único exportado (usado também internamente). +- **P2-3:** teste 'purges orphaned renames during the missing-consent fixture setup' sem cleanup no finally — adicionado. +- **P2 (reviewer 2):** down-test da Onda0 lançava sem rollback quando a sessão da transação faltava — rollback adicionado antes do throw. +- **Débitos registrados (não-fix):** fault `beforeRestoreLeaseAcquire` vestigial com janela exclusiva (só tem efeito com `serializeWindow: false` — documentar/remover); exposição transitória do gap do self-heal (ms, mesma classe residual aceita); `privacy_policy.published=false` persiste após o down-test (comportamento D9, inalterado); micro-otimização dos selects do reference-check; callback `execute` do purge podia receber `payload` direto. + +## Riscos e mitigação (medidos) + +- **Fila nova na janela exclusiva / provision exclusivo** (consumidores esperam ~200–500ms): medido 8/8 verdes sob 6 workers — sem timeouts. +- **F1 purge apagar linha viva:** impossível por construção (renomeador vivo segura o exclusivo; purge roda sob o exclusivo); testes plantam órfãos sob lease e limpam no finally. +- **Down-test atômico:** consumers com FK no meio do delete+restore transacional esperam o commit (speculative insertion) e progridem — classe FK eliminada (medido). +- **F1b deletar contato referenciado:** o filtro de outras-referências roda antes do delete; teste cobre ambos os lados (deleta só órfão; preserva referenciado). + +## Aceite de engenharia + +- [x] Run abortado (rename órfão plantado) não envenena o run seguinte (F1 — repros + suíte) +- [x] Nenhum consumidor observa a ausência commitada da janela missing-consent (janela exclusiva + contrato testado) +- [x] Cascata de timeouts não reproduz sob 6 workers ×12 (F2 — repro 12/12 vs baseline 1/4) +- [x] Review /simplify: P1-1 e P2s aplicados; débitos registrados +- [x] Invariantes AGENTS/engineering-standards; só testes/helpers; sem migration; zero `src/` (tsc, lint, knip, cycles, format, unit 1938, int 701, e2e paridade com baseline) diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 81186c88..bbbc4cf4 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -233,8 +233,9 @@ const nextAllocatedMunicipalityIndex = async ( * Delete campaign rows attached to a freshly claimed municipality. The allocator * guarantees no OTHER live test owns this municipality, so anything found here is * residue from an aborted previous run that would poison count assertions. + * Also deletes the CONTACT rows those residue supporters orphaned (D10 F1). */ -const purgeMunicipalityResidue = async ( +export const purgeMunicipalityResidue = async ( payload: Payload, municipalityID: number, ): Promise => { @@ -273,7 +274,6 @@ const purgeMunicipalityResidue = async ( where: { municipality: { equals: municipalityID } }, depth: 0, pagination: false, - select: {}, }), payload.find({ collection: 'activity', @@ -336,6 +336,63 @@ const purgeMunicipalityResidue = async ( depth: 0, }) } + + // D10 F1: an aborted run leaves residue supporters AND their contact rows. + // The supporters are gone above; delete the contacts they orphaned (no other + // join references them) so the residue stops accumulating — same reference + // rule as the production `removeSupporterData` flow, reimplemented here so + // the fixture never runs production code under test. + const residueContactIDs = [ + ...new Set( + supporters.docs.map((doc) => relationId(doc.contact)).filter((id) => id !== undefined), + ), + ] + if (residueContactIDs.length > 0) { + const [leadershipRefs, signatureRefs, subscriptionRefs, supporterRefs] = await Promise.all([ + payload.find({ + collection: 'leadership', + where: { contact: { in: residueContactIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'signature', + where: { contact: { in: residueContactIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'subscription', + where: { contact: { in: residueContactIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'supporter', + where: { contact: { in: residueContactIDs } }, + depth: 0, + pagination: false, + }), + ]) + const referenced = new Set() + for (const doc of [ + ...leadershipRefs.docs, + ...signatureRefs.docs, + ...subscriptionRefs.docs, + ...supporterRefs.docs, + ]) { + const contactID = relationId(doc.contact) + if (contactID !== undefined) referenced.add(contactID) + } + const orphanContactIDs = residueContactIDs.filter((id) => !referenced.has(id)) + if (orphanContactIDs.length > 0) { + await payload.delete({ + collection: 'contact', + where: { id: { in: orphanContactIDs } }, + depth: 0, + }) + } + } } export class CampaignFixtures { diff --git a/tests/helpers/testDatabaseLease.ts b/tests/helpers/testDatabaseLease.ts index 0b67ba5b..da79bfa2 100644 --- a/tests/helpers/testDatabaseLease.ts +++ b/tests/helpers/testDatabaseLease.ts @@ -88,6 +88,41 @@ export const restoreConsentRows = async ( } } +/** + * Keys the consent fixtures rename the canonical stable row to while a test + * exercises the fail-closed "key absent" state. A run aborted between the + * rename commit and the fixture restore leaves the canonical key absent and an + * orphan row holding one of these names; without a purge, the next run + * snapshots the key as absent, creates a fresh row, and the same rename then + * hits the UNIQUE `consent_key_idx` — the pollution is self-sustaining (D10 F1). + */ +const ORPHANED_CONSENT_RENAME_KEYS = [ + 'temporarily-renamed-consent', + 'chave-renomeada', + 'chave-temporariamente-ausente', +] as const + +/** + * Deletes orphaned consent rows left by an aborted run: the explicit temporary + * rename keys. MUST be called while holding the lease key's EXCLUSIVE lease. + * The 3 names are produced by NO live test anymore (the fail-closed rename + * tests vacate the canonical row to PRIVATE keys, D10 review finding P1-1), so + * a row carrying one of them is guaranteed orphaned — the only producer left + * is the self-heal repro's own plant, which runs under this key's exclusive + * lease immediately before the healing fixture. + */ +export const purgeOrphanedConsentRenames = async ( + execute: (query: ReturnType) => Promise, +): Promise => { + await execute(sql` + DELETE FROM "consent" + WHERE "key" IN (${sql.join( + ORPHANED_CONSENT_RENAME_KEYS.map((key) => sql`${key}`), + sql`, `, + )}) + `) +} + export const CAMPAIGN_INVITE_CONSENT_LEASE_KEY = 'campaign-invite-consent' export const SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY = 'supporter-registration-consent' export const SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY = 'supporter-vote-intention-consent' @@ -104,7 +139,7 @@ export type LeasedConsentFixture = { consentKey: string leaseKey: string } -const fixtureConsentText: Consent['text'] = { +export const fixtureConsentText: Consent['text'] = { root: { type: 'root', children: [ @@ -385,7 +420,7 @@ const withDatabaseLease = async ( } } -const withTestDatabaseLease = async ( +export const withExclusiveTestDatabaseLease = async ( payload: Payload, leaseKey: string, operation: () => Promise, @@ -394,7 +429,7 @@ const withTestDatabaseLease = async ( payload, leaseKey, acquireTestDatabaseLease, - 'The leased operation and database lease release both failed.', + 'The exclusive leased operation and database lease release both failed.', operation, ) @@ -415,7 +450,13 @@ export const ensureLeasedConsent = async ( payload: Payload, { consentKey, leaseKey }: LeasedConsentFixture, ): Promise => - withTestDatabaseLease(payload, leaseKey, async () => { + withExclusiveTestDatabaseLease(payload, leaseKey, async () => { + // D10 F1: a run aborted mid-rename leaves an orphan row holding a + // temporary name and the canonical key absent; the purge clears it so the + // ensure below can recreate the canonical row without the next rename + // hitting the UNIQUE constraint. Autocommit is safe: we hold the + // exclusive lease, so no live renamer can be mid-flight. + await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query)) const existing = await payload.find({ collection: 'consent', where: { key: { equals: consentKey } }, @@ -440,6 +481,17 @@ export const withLeasedConsent = async ( { consentKey, leaseKey }: LeasedConsentFixture, operation: (consent: Consent) => Promise, ): Promise => { + // D10 F2 (measurement-driven): the operation holds the SHARED consent lease. + // Holding exclusive across the operation serialized every writer on the key + // into a 17-waiter queue and a 15s cascade under 6 workers; running the + // operation UNLEASED let a parallel fixture's created-row cleanup delete the + // canonical row out from under a consumer (FK violation). Shared is the + // stable middle: consumers never serialize against each other (their writes + // touch disjoint per-test rows, so no lock-order cycle is constructible — + // every advisory holder acquires at most one lease at a time, and Onda0 + // acquires its three keys in a fixed order) and the exclusive + // missing-consent window blocks every consumer while the canonical key is + // absent, so no consumer observes the transient state. for (;;) { await ensureLeasedConsent(payload, { consentKey, leaseKey }) const lease = await beginTestDatabaseLease(payload, leaseKey, 'shared') @@ -500,6 +552,13 @@ export const withMissingInviteConsentFixture = async ( let setupError: unknown try { await faults.beforeSnapshotSetup?.() + // D10 F1: clear orphaned renames left by an aborted run BEFORE snapshotting + // (we hold the exclusive setup lease, so no live renamer exists). The + // DELETE runs in autocommit, NOT in the lease transaction: an uncommitted + // delete would keep its row lock and the fixture's own operation would + // self-deadlock when it re-uses the purged key (observed on the first D10 + // implementation). + await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query)) snapshot = rowsFrom( await setupLease.transaction.execute(sql` SELECT "id", "key", "text", "updated_at", "created_at" @@ -545,21 +604,23 @@ export const withMissingInviteConsentFixture = async ( } if (setupError !== undefined) throw setupError - // Serialize the operation window under a SHARED lease (same precedent as - // onda0Provision's leases on the stable keys): parallel spec files must - // never observe the committed-absent state, or their ensure-then-read - // helpers create an ephemeral row that this restore deletes out from under - // them. Shared is enough — the ephemeral-row creators (ensures and other - // fixtures) are all EXCLUSIVE holders, which a shared window blocks without - // stalling the harmless shared readers behind it (the 15s test budget - // stayed tight under load with an exclusive window). + // Serialize the operation window under an EXCLUSIVE lease (D10 F2, + // measurement-driven change from D9's shared window): every consumer of the + // key — the shared `withLeasedConsent` holders AND the exclusive fixtures — + // is blocked while the canonical key is committed-absent, so no consumer can + // observe the transient absence or read/reference a row the restore deletes + // out from under it (baseline load runs flaked 3/4 with exactly this class: + // 'Consentimento ainda não configurado' in redeem tests and + // `consent_id` FK violations). The window is short (a fail-closed action or + // a no-op), so the queueing behind it is bounded — the D9-era 15s budget + // blowout happened with the Onda0 provision holding EXCLUSIVE for + // multi-second writes; since D10 the provision holds exclusive too, but + // only for ~500ms of short upserts. // `serializeWindow: false` keeps the unleased window for operations that // deliberately exercise concurrent consent writers. NOTE: with the window // leased, the operation must NOT acquire the same lease itself (e.g. via // ensureLeasedConsent on the same key) — that would self-deadlock. - const windowLease = serializeWindow - ? await beginTestDatabaseLease(payload, leaseKey, 'shared') - : undefined + const windowLease = serializeWindow ? await beginTestDatabaseLease(payload, leaseKey) : undefined // Close the gap between the setup's committed delete and this acquisition: // a writer that queued during the setup (advisory locks grant FIFO) can @@ -653,6 +714,12 @@ export const withMutableConsentFixture = async ( let operationError: unknown try { + // D10 F1: clear orphaned renames left by an aborted run BEFORE snapshotting + // (we hold the exclusive lease, so no live renamer exists). Autocommit, + // never inside the lease transaction — an uncommitted delete would keep + // its row lock and the fixture's own operation would self-deadlock when it + // re-uses the purged key. + await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query)) snapshot = rowsFrom( await lease.transaction.execute(sql` SELECT "id", "key", "text", "updated_at", "created_at" diff --git a/tests/int/campaignFixtures.int.spec.ts b/tests/int/campaignFixtures.int.spec.ts index 4631a7ba..a6a8b676 100644 --- a/tests/int/campaignFixtures.int.spec.ts +++ b/tests/int/campaignFixtures.int.spec.ts @@ -5,7 +5,11 @@ import { beforeAll, describe, expect, it } from 'vitest' import type { Consent } from '@/payload-types' import config from '@/payload.config' -import { createCampaignFixtures, withCampaignFixtures } from '../helpers/campaignFixtures' +import { + createCampaignFixtures, + purgeMunicipalityResidue, + withCampaignFixtures, +} from '../helpers/campaignFixtures' import { withInviteConsent, withMutableConsentFixture } from '../helpers/testDatabaseLease' let payload: Payload @@ -40,7 +44,8 @@ const exists = async ( | 'votePledge' | 'campaignDemand' | 'organization' - | 'municipalityUpdate', + | 'municipalityUpdate' + | 'supporter', id: number, ): Promise => { const result = await payload.find({ @@ -248,10 +253,15 @@ describe('campaign integration fixtures', () => { let consentID = 0 await withMutableConsentFixture(payload, async (configuredConsent) => { + // The vacating rename uses a PRIVATE key, deliberately OUTSIDE the + // orphan-rename purge list: the canonical row is shared with parallel + // spec files and any key the purge recognizes could be deleted by a + // concurrent fixture's cleanup mid-operation (D10 F2 measurement). + const vacatedKey = `owned-consent-vacated-${configuredConsent.id}` await payload.update({ collection: 'consent', id: configuredConsent.id, - data: { key: `temporarily-unkeyed-${configuredConsent.id}` }, + data: { key: vacatedKey }, depth: 0, }) await withCampaignFixtures(payload, async (fixtures) => { @@ -397,4 +407,42 @@ describe('campaign integration fixtures', () => { }) expect(persistedMunicipality.advisors ?? []).toEqual([]) }) + + it('purges residue supporters and the contacts they orphaned on a municipality claim', async () => { + // D10 F1: an aborted run leaves supporters AND their contact rows on the + // claimed municipality. The purge must delete the orphaned contacts (no + // other join references them) while preserving contacts that a live + // leadership outside the purged municipality still references. + const fixtures = createCampaignFixtures(payload) + try { + const municipality = await fixtures.getMunicipality() + const coordinator = await fixtures.createCampaignUser('coordinator') + const orphanContact = await fixtures.createContact() + const residueSupporter = await fixtures.createSupporter({ + contact: orphanContact, + municipality, + createdBy: coordinator, + }) + const preservedContact = await fixtures.createContact() + await fixtures.createSupporter({ + contact: preservedContact, + municipality, + createdBy: coordinator, + }) + const otherMunicipality = await fixtures.getMunicipality() + await fixtures.createLeadership({ + contact: preservedContact, + municipalities: [otherMunicipality.id], + createdBy: coordinator, + }) + + await purgeMunicipalityResidue(payload, municipality.id) + + expect(await exists('supporter', residueSupporter.id)).toBe(false) + expect(await exists('contact', orphanContact.id)).toBe(false) + expect(await exists('contact', preservedContact.id)).toBe(true) + } finally { + await fixtures.cleanup() + } + }) }) diff --git a/tests/int/campaignInvite.int.spec.ts b/tests/int/campaignInvite.int.spec.ts index 1875d0dd..6f2ec591 100644 --- a/tests/int/campaignInvite.int.spec.ts +++ b/tests/int/campaignInvite.int.spec.ts @@ -1611,7 +1611,7 @@ describe('campaign invite domain', () => { await payload.update({ collection: 'consent', id: consent.id, - data: { key: 'chave-renomeada' }, + data: { key: `consent-vacated-${consent.id}` }, }) await expect( @@ -1675,7 +1675,7 @@ describe('campaign invite domain', () => { await payload.update({ collection: 'consent', id: consent.id, - data: { key: 'chave-temporariamente-ausente' }, + data: { key: `consent-vacated-${consent.id}` }, }) const submission = { token: invite.token, diff --git a/tests/int/onda0Provision.int.spec.ts b/tests/int/onda0Provision.int.spec.ts index 07281a63..0622eead 100644 --- a/tests/int/onda0Provision.int.spec.ts +++ b/tests/int/onda0Provision.int.spec.ts @@ -20,6 +20,7 @@ import { SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY, restoreConsentRows, + withExclusiveTestDatabaseLease, withSharedTestDatabaseLease, type ConsentSnapshot, } from '../helpers/testDatabaseLease' @@ -40,22 +41,18 @@ describe('Onda 0 provision (integration)', () => { }) it('upserts consent keys and publishes privacy-policy idempotently', async () => { - // Shared leases on the stable keys other spec files exercise: the - // provision UPSERTS the shared rows and the spec below REMOVES them, so - // the writes must never interleave a parallel file's reads of the same - // rows (D9 flake). SHARED — not exclusive — because this spec only - // touches the four canonical keys and an exclusive holder here widened - // the consent-lease deadlock surface with the invite-domain writers - // (cascade under parallel load). The missing-consent fail-closed paths in - // other files now serialize their own windows under the lease, and the - // down-test below restores the removed rows with their original ids, so - // the shared rows are never observed absent or re-created with a new id. - // (The fourth Onda0 key, campanha-notificacoes-push, has no lease - // constant because no spec file writes it today; the down-test below - // still restores it.) - await withSharedTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, () => - withSharedTestDatabaseLease(payload, SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, () => - withSharedTestDatabaseLease( + // EXCLUSIVE leases on the stable keys other spec files exercise (D10 F2): + // the provision REWRITES the shared rows' texts, and a consumer that reads + // the canonical consent between two of those writes would assert against a + // text that changed under it (observed: campaignInviteUi's preview + // `requiresConsent`/`consentData` mismatch). Exclusive serializes every + // consumer for the ~500ms of the provision — bounded, unlike the pre-D10 + // cascade where the ONDA0 exclusive sat behind a queue of EXCLUSIVE + // writers. The down-test below restores the removed rows with their + // original ids atomically, so the shared rows are never observed absent. + await withExclusiveTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, () => + withExclusiveTestDatabaseLease(payload, SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, () => + withExclusiveTestDatabaseLease( payload, SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY, async () => { @@ -113,34 +110,54 @@ describe('Onda 0 provision (integration)', () => { expect(privacyRows.rows[0]?.published).toBe(true) expect(privacyRows.rows[0]?.body).toBeTruthy() - await removeOnda0ConsentAndPrivacyDb(db) + // The down-migration must stay invisible to parallel spec files: + // delete + count + restore run in ONE transaction, so no consumer + // ever observes the stable keys committed-absent or re-created + // with a new id (D10 F2 — the non-atomic version flaked load runs + // with `consent_id` FK violations and fail-closed reads). + const transactionID = await payload.db.beginTransaction() + if (transactionID === null) { + throw new Error('Expected a PostgreSQL transaction for the Onda0 down.') + } + const transactionDb = payload.db.sessions?.[String(transactionID)]?.db as + | typeof db + | undefined + if (!transactionDb) { + await payload.db.rollbackTransaction(transactionID).catch(() => undefined) + throw new Error('The Onda0 down transaction session is unavailable.') + } + try { + await removeOnda0ConsentAndPrivacyDb(transactionDb) - const consentAfterDown = await db.execute(sql` - SELECT count(*)::integer AS count - FROM "consent" - WHERE "key" IN (${sql.join( - ONDA0_CONSENT_KEY_LIST.map((key) => sql`${key}`), - sql`, `, - )}) - `) - expect(consentAfterDown.rows[0]?.count).toBe(0) + const consentAfterDown = await transactionDb.execute(sql` + SELECT count(*)::integer AS count + FROM "consent" + WHERE "key" IN (${sql.join( + ONDA0_CONSENT_KEY_LIST.map((key) => sql`${key}`), + sql`, `, + )}) + `) + expect(consentAfterDown.rows[0]?.count).toBe(0) - const privacyAfterDown = await db.execute(sql` - SELECT "published" - FROM "privacy_policy" - LIMIT 1 - `) - expect(privacyAfterDown.rows[0]?.published).toBe(false) + const privacyAfterDown = await transactionDb.execute(sql` + SELECT "published" + FROM "privacy_policy" + LIMIT 1 + `) + expect(privacyAfterDown.rows[0]?.published).toBe(false) - // The down-migration must stay a no-op for parallel spec files: - // restoring the removed rows with their ORIGINAL ids (inside the - // exclusive lease, invisible to them) keeps the stable keys - // present with unchanged ids, so no other file's restore assertion - // can observe the absence (D9 flake). - await restoreConsentRows( - (query) => db.execute(query), - consentRows.rows as ConsentSnapshot[], - ) + // Restore the removed rows with their ORIGINAL ids inside the + // same transaction — the commit publishes delete+restore as one + // atomic state change. + await restoreConsentRows( + (query) => transactionDb.execute(query), + consentRows.rows as ConsentSnapshot[], + ) + await payload.db.commitTransaction(transactionID) + } catch (error) { + await payload.db.rollbackTransaction(transactionID).catch(() => undefined) + throw error + } }, ), ), diff --git a/tests/int/testDatabaseLease.int.spec.ts b/tests/int/testDatabaseLease.int.spec.ts index 7bb01d0b..4231385c 100644 --- a/tests/int/testDatabaseLease.int.spec.ts +++ b/tests/int/testDatabaseLease.int.spec.ts @@ -10,6 +10,8 @@ import { acquireTestDatabaseLease, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, ensureLeasedConsent, + fixtureConsentText, + purgeOrphanedConsentRenames, startTestDatabaseLeaseAcquisition, waitForAdvisoryLockWaiter, withInviteConsent, @@ -182,6 +184,7 @@ describe('test database lease', () => { const before = await withInviteConsent(payload, async (consent) => consent) let writerAcquisition: Promise | undefined + let readerAcquisition: Promise | undefined try { await withMissingInviteConsentFixture(payload, async () => { let writerEntered = false @@ -202,14 +205,44 @@ describe('test database lease', () => { }) expectExactWaiter(waiting, writerPID, 'ExclusiveLock') expect(writerEntered).toBe(false) + + // D10 F2: the window is EXCLUSIVE, so even a SHARED consumer queues + // while the canonical key is committed-absent — no consumer can + // observe the transient absence (baseline load runs flaked 3/4 with + // consumers reading the absent key or referencing a row the restore + // deleted out from under them). The reader is NOT awaited here: it + // only unblocks when this window releases, which is after the + // operation returns. + let readerEntered = false + const pendingReader = startTestDatabaseLeaseAcquisition( + payload, + CAMPAIGN_INVITE_CONSENT_LEASE_KEY, + 'shared', + ) + readerAcquisition = pendingReader.acquisition.then((lease) => { + readerEntered = true + return lease + }) + const readerPID = await pendingReader.backendPID + const readerWaiting = await waitForAdvisoryLockWaiter(payload, { + key: `test:${CAMPAIGN_INVITE_CONSENT_LEASE_KEY}`, + mode: 'ShareLock', + waiterPID: readerPID, + }) + expectExactWaiter(readerWaiting, readerPID, 'ShareLock') + expect(readerEntered).toBe(false) }) } finally { - // Release the waiter lease even when an assertion above fails, so the - // pending acquisition never blocks parallel spec files until teardown. + // Release the waiter leases even when an assertion above fails, so the + // pending acquisitions never block parallel spec files until teardown. if (writerAcquisition) { const writer = await writerAcquisition await writer.release() } + if (readerAcquisition) { + const reader = await readerAcquisition + await reader.release() + } } const after = await withInviteConsent(payload, async (consent) => consent) @@ -473,4 +506,176 @@ describe('test database lease', () => { const subsequent = await acquireTestDatabaseLease(payload, leaseKey) await subsequent.release() }) + + it('purges orphaned consent renames left by an aborted run on ensure', async () => { + // D10 F1: every consent fixture must clear the rename shapes an aborted + // run can leave behind before snapshotting, or the next run's rename hits + // the UNIQUE constraint. Legitimate rows (the private key below) survive. + const privateKey = `purge-ensure-${Date.now()}-${Math.random()}` + const privateLeaseKey = `purge-ensure-lease-${Date.now()}-${Math.random()}` + const legitKey = `purge-legit-${Date.now()}-${Math.random()}` + const orphanIDs: number[] = [] + let legitID = 0 + // The orphans are planted under the key's EXCLUSIVE lease: the rename + // keys are LIVE inside other spec files' fixtures, and an unleased + // create could hit their UNIQUE constraint mid-rename (D10 F2 load + // measurement). The lease start also purges any leftover from a previous + // failed run of this very test, so the plant never collides with it. + const plantLease = await acquireTestDatabaseLease(payload, privateLeaseKey) + try { + await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query)) + for (const key of [ + 'temporarily-renamed-consent', + 'chave-renomeada', + 'chave-temporariamente-ausente', + ]) { + const orphan = await payload.create({ + collection: 'consent', + data: { key, text: fixtureConsentText }, + depth: 0, + }) + orphanIDs.push(orphan.id) + } + const legit = await payload.create({ + collection: 'consent', + data: { key: legitKey, text: fixtureConsentText }, + depth: 0, + }) + legitID = legit.id + } finally { + await plantLease.release() + } + + try { + const configured = await ensureLeasedConsent(payload, { + consentKey: privateKey, + leaseKey: privateLeaseKey, + }) + expect(configured.key).toBe(privateKey) + + for (const orphanID of orphanIDs) { + const orphan = await payload.find({ + collection: 'consent', + where: { id: { equals: orphanID } }, + depth: 0, + limit: 1, + }) + expect(orphan.docs).toHaveLength(0) + } + expect( + await payload.findByID({ collection: 'consent', id: legitID, depth: 0 }), + ).toMatchObject({ key: legitKey }) + } finally { + // Cleanup runs even on failure — a failed run of this very test must + // not leave the orphans it planted behind (self-healing tests). + for (const orphanID of orphanIDs) { + await payload + .delete({ collection: 'consent', id: orphanID, depth: 0 }) + .catch(() => undefined) + } + if (legitID > 0) { + await payload + .delete({ collection: 'consent', id: legitID, depth: 0 }) + .catch(() => undefined) + } + } + }) + + it('purges orphaned renames during the missing-consent fixture setup', async () => { + const privateKey = `purge-missing-${Date.now()}-${Math.random()}` + const privateLeaseKey = `purge-missing-lease-${Date.now()}-${Math.random()}` + // Planted under the exclusive lease (the rename keys are live inside + // other spec files' fixtures — an unleased create could hit their UNIQUE + // constraint mid-rename). The lease start also purges any leftover from + // a previous failed run of this very test. + const plantLease = await acquireTestDatabaseLease(payload, privateLeaseKey) + let orphanID = 0 + try { + await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query)) + orphanID = ( + await payload.create({ + collection: 'consent', + data: { key: 'chave-temporariamente-ausente', text: fixtureConsentText }, + depth: 0, + }) + ).id + } finally { + await plantLease.release() + } + + await withMissingInviteConsentFixture(payload, async () => undefined, { + consentKey: privateKey, + leaseKey: privateLeaseKey, + }) + + try { + const orphan = await payload.find({ + collection: 'consent', + where: { id: { equals: orphanID } }, + depth: 0, + limit: 1, + }) + expect(orphan.docs).toHaveLength(0) + } finally { + // A failed run of this very test must not leave its plant behind. + await payload.delete({ collection: 'consent', id: orphanID, depth: 0 }).catch(() => undefined) + } + }) + + it('self-heals the canonical invite consent after an aborted rename on the next fixture run', async () => { + // The exact residue an aborted run leaves: the canonical row committed + // with a temporary key and the snapshot lost. The plant holds the + // exclusive lease so parallel spec files never observe the state; the + // fixture's own purge then heals it — the in-fixture rename must succeed + // instead of hitting the UNIQUE `consent_key_idx`. The lease start also + // purges any leftover from a previous failed run of this very test. + const plantLease = await acquireTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY) + try { + await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query)) + const canonical = await payload.find({ + collection: 'consent', + where: { key: { equals: 'lideranca-autopreenchimento' } }, + limit: 1, + sort: 'id', + depth: 0, + }) + if (!canonical.docs[0]) { + throw new Error( + 'The canonical invite consent row must exist before the aborted-rename repro.', + ) + } + await payload.update({ + collection: 'consent', + id: canonical.docs[0].id, + data: { key: 'chave-renomeada' }, + depth: 0, + }) + } finally { + await plantLease.release() + } + + let renamedDuringFixture = false + await withMutableConsentFixture(payload, async (consent) => { + // Without the F1 purge this rename throws "Valor deve ser único". + await payload.update({ + collection: 'consent', + id: consent.id, + data: { key: 'chave-renomeada' }, + depth: 0, + }) + renamedDuringFixture = true + }) + expect(renamedDuringFixture).toBe(true) + + // Second run proves the run following the healed one stays clean. + await withMutableConsentFixture(payload, async () => undefined) + + const orphans = await payload.find({ + collection: 'consent', + where: { key: { equals: 'chave-renomeada' } }, + depth: 0, + pagination: false, + }) + expect(orphans.totalDocs).toBe(0) + }) })