fix(D9): flake do consent restore sob carga — janela leaseada, Onda0 shared com restore de ids, asserções por snapshot

- withMissingInviteConsentFixture serializa a janela de operação sob lease
  (serializeWindow default true; teste de ensures concorrentes migrou para
  key/lease privada; re-verificação/re-delete do gap setup→janela)
- spec Onda0 volta a leases shared (exclusivo causava ABBA com escritores de
  invite) e o down-migration restaura as linhas com os ids originais
  (restoreConsentRows compartilhado; no-op para o resto da suíte)
- asserções de restore referenciam o snapshot do fixture (contrato real);
  rollback asserta id+key; ORDER BY/sort nas leituras limit-1
- contraprova: suíte int completa ×5 + 6 arquivos da key ×13/14 verdes
This commit is contained in:
Francisco Solla 2026-08-10 19:22:28 -03:00
parent f460dac6cf
commit 9e2b22ef86
6 changed files with 407 additions and 96 deletions

View file

@ -6,6 +6,10 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-10):** **OPS33** (Issue #595) — `worktree next` **claima deterministicamente** antes de criar o worktree: mesma fila/ordem e lock otimista do `pnpm agent:claim`, com o claim extraído para `scripts/lib/agent-github.mjs` (`claimIssue` — lock + flip + comentário; `claimBriefLines` — brief compartilhado; `claimQueueEntry` — shape para o reopen; `claimTargetVerdict` — decisão pura testada) e o `agent-claim.mjs` virou CLI fino com a **mesma saída**; claim falhou (fila vazia/race/`--issue` inválido) → motivo e saída **sem worktree órfão** (branch validada antes do claim; hint de falha pós-claim). `--issue <N>` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; recupera também falha pós-claim); a saída imprime o brief e avisa "já claimada — NÃO rodar `pnpm agent:claim`". O **launch** do terminal agora envia `/work-issue --issue <N>` (prompt citado na diretiva) e a função `worktree()` de `.agents/shell/worktree.sh` passou a **tokenizar a diretiva via xargs** (quote-aware, nunca eval) — o valor do `--prompt` carrega espaço. `--stay` suprime cd+launch mas o claim acontece; o comando `/worktree` roda o mesmo script (claim incluso, sem launch). Guard `-B` contra reset de commits de sessão no reopen. Pool intacto (`claimIssueForPool` coordenado). Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue` (sai "read-only"), AGENT-OPS. Sem migration/dados/access/UI. Intenção [`worktree-next-claima-issue-deterministicamente.md`](plans/worktree-next-claima-issue-deterministicamente.md); impl [`worktree-next-claima-issue-deterministicamente-impl.md`](plans/worktree-next-claima-issue-deterministicamente-impl.md).
**Recently resolved (2026-08-10):** **D9** (Issue #596) — flake do consent restore do `testDatabaseLease` sob carga eliminado. **Caracterização com reprodução** (3 falhas em 8 runs da suíte int completa; depois isolada a 2 arquivos): o assertion "restores the exact configured consent after deletion and recreation" via leituras voláteis de uma linha canônica que escritores legítimos de outras specs deletavam/recriavam no meio do teste. **Causa raiz em três camadas:** (1) a janela de operação de `withMissingInviteConsentFixture` era a única transição de estado sem lease da suíte (outras specs criavam linha efêmera que o restore deletava sob elas); (2) `removeOnda0ConsentAndPrivacyDb` rodava sob leases **shared** e deletava as 4 keys estáveis no meio de testes alheios (o id restaurado sumia irreversivelmente); (3) asserções de restore comparavam contra leituras `before` que o provision Onda0 podia re-textar legitimamente. **Fix (só testes, zero `src/`):** janela de operação sob lease shared (`serializeWindow`, default true; o teste de ensures concorrentes migrou para key/lease privada — único caso que deliberadamente mantém janela sem lease, com re-verificação/re-delete do gap setup→janela; exclusivo descartado: alargava o ABBA com os escritores de invite sob carga); spec Onda0 com leases **shared** + down-migration **restaura as linhas com os ids originais** dentro da janela (`restoreConsentRows` compartilhado; no-op para o resto da suíte); asserções de restore referenciam o **snapshot do fixture** (contrato real), rollback asserta id+key; `ORDER BY id`/`sort: 'id'` nas leituras limit-1; core `withDatabaseLease`; `ensureInviteConsent` órfão removido (knip). Contraprova: suíte completa ×5 + par mínimo ×8 + 6 arquivos da key ×13/14 verdes (runs com restart externo do Postgres descartados por uptime). Intenção [`test-database-lease-consent-flake.md`](plans/test-database-lease-consent-flake.md); impl [`test-database-lease-consent-flake-impl.md`](plans/test-database-lease-consent-flake-impl.md).
**Recently resolved (2026-08-10):** **B193** (Issue #542) — card de município mobile denso + edit-where-you-see + última atualização expandível. O card de `/campanha/municipios` no mobile deixou o grid de labels e virou composição do wireframe: cabeçalho nome+território com **posição de voto 2022 à direita** (percentual em cima; colocação · votos embaixo), **barra de cenários** (pessimista·central·otimista + marcador do ativo — `VoteEstimateScenarioStrip` como trigger da sheet de votos existente), **chips** classe/tendência/nível (tendência e nível com label à esquerda, dentro do trigger — tocar no label abre a sheet; classe com razão via tooltip que abre no toque), **3 grupos de avatares com label** (Assessores/Lideranças/Dobradinhas — um avatar por entidade, wrap, sem cap; leitura de assessores para não-coordenador), rodapé **"Última atualização há X dias" + chevron que expande o limite inferior** revelando o último card de atualização (badges + corpo + autor · data, sem moldura) + CTA "Registrar atualização" (sheet existente); **sem atualização → o rodapé vira CTA direto** (sem chevron/expansão); prioritário = **borda lateral direita 6px** `border-primary` no lugar do flag; Salvador read-only; "Cobertura" (conta da cadeira) sai do card (fica na tabela/detalhe — gate humano); rodapé mede a idade da **atualização** (não o frescor update∨pledge — coluna desktop intacta). **C109 corrigida no caminho (a "sheet quebra ao abrir" era real):** os callbacks dos controles (autosave `onOpenChange`, `handleOpenChange` do nível) são recriados por render — qualquer re-render com a sheet aberta (digitar no sheet!) fazia o host descartar a sessão e o corpo sumia; sessões do host agora são **keyed por id de sessão** (imune a identidade) e o **footer customizado** (submit + `DialogClose`) passou a ser **registrado e renderizado pelo host dentro da árvore do Drawer** (portal perdia o Dialog context — o e2e antigo só abria a sheet de tendência, sem footer custom, por isso a C109 não reproduzia). Controles ganharam prop `trigger`/`triggerClassName` (precedente `RelationChipCell.trigger`); `MunicipalityRelationAvatarStack` ganhou modo wrap. Dado: `MunicipalityListViewModel.lastUpdate` (batch por página via `loadMunicipalityLastUpdates`, scoped por portfólio). E2E novos (390×844): barra abre sheet e reflete valores salvos, chip Nível abre a sheet, borda de prioridade (6px + cor), expansão/CTA vazio. Sem migration/access/Consent. Intenção [`municipios-card-mobile-edit-where-you-see.md`](plans/municipios-card-mobile-edit-where-you-see.md); impl [`municipios-card-mobile-edit-where-you-see-impl.md`](plans/municipios-card-mobile-edit-where-you-see-impl.md).
**Recently resolved (2026-08-10):** **OPS28** (Issue #584) — `worktree next`/`plan` passam a aplicar o **seed mínimo** além das migrations em cada banco provisionado (`migrate` + `db:seed:minimal` por banco, dev e test) — paridade com a CI, que roda o mesmo par antes do e2e: o e2e de worktree deixa de divergir do de CI (specs dependentes dos pins `priority: 'alta'` de `salvador-ze-1`/`camacari` — FAB suggest e `campaignSavedFilters` — falhavam só localmente com banco migrado cru 435×normal, o padrão que o ledger TECH-DEBT row 19 ficha como "masks real failures"). `--no-migrate` pula migrate **e** seed (o seed depende do catálogo migrado); re-provisionamento continua idempotente (upserts). Fallback sem Docker (Cursor Cloud) também roda migrate+seed nos `teqo`/`teqo_test` compartilhados (decisão de produto do gate: paridade total). **Guard de regressão** `tests/e2e/seedParity.setup.e2e.spec.ts` no projeto `setup` do Playwright: falha cedo com mensagem clara ("rode `pnpm migrate && pnpm db:seed:minimal`") se o banco de teste não estiver seedado — dependentes do setup pulam, nada de spec vermelho confuso; a evidência checada são os **users do seed** (`MINIMAL_CAMPAIGN_USERS`), deliberadamente não os pins `priority: 'alta'` — o cleanup dos fixtures e2e reseta `priority` dos municípios que toca (round-robin por sequência persistente), então pins morrem legitimamente após runs repetidas no mesmo banco de worktree e só o `pnpm worktree next` re-aplica (guard nunca checaria pins); `scripts/worktree.mjs` entrou em `HIGH_RISK_EXACT` (mudança no provisionador roda a suíte cheia na CI). Sem migration/access/Consent/UI. Intenção [`worktree-seed-minimal-paridade-e2e-ci.md`](plans/worktree-seed-minimal-paridade-e2e-ci.md); impl [`worktree-seed-minimal-paridade-e2e-ci-impl.md`](plans/worktree-seed-minimal-paridade-e2e-ci-impl.md).
**Recently resolved (2026-08-10):** **OPS34** (Issue #599) — o job `e2e` do CI (o mais longo da cascata, 435 s medidos no run 31412425553: ~67 s fixos + build 172 s + testes 196 s) roda agora **em 2 shards paralelos** (`--shard=N/2`, matrix `fail-fast: false`, build por shard — opção A da intenção validada pela medição: por shard ≈ 5–5,5 min; artefato de build compartilhado rejeitado porque os shards só começam após o build → wall maior). **Cadeia de projetos do Playwright (`setup → campaign → frontend → admin`) + prewarm do `setup` viraram dev-only:** `playwright.config.ts` ganhou `isProdMode = CI || E2E_PROD=1` (mesma condição do `webServer.command`, agora reusada) — em prod o projeto `setup` sai do array (arquivo não casado = não coletado) e as `dependencies` zeram (famílias em paralelo contra o build de produção, onde não há compile a preaquecer); dev intocado (provado: `--list` mostra `[setup]` só sem CI; smoke e2e dev rodou setup→campaignAuth). `ci-pr.yml`: o job `scope` emite `e2e_matrix`/`e2e_shards` via novo `e2eShardConfig(mode)` em `scripts/lib/test-affected-core.mjs` (fonte única, unit-pinada: full → `[1,2]`/2, selected/none → `[1]`/1 — pagar 2 builds por 3 specs é desperdício); `ci.yml` hardcodeia `[1,2]`/2 com comentário apontando o single-source. `PLAYWRIGHT_WORKERS: 2` explícito no env dos jobs e2e (aceite: explícito, default local intocado). Armadilha registrada nos workflows: flags do playwright vão **sem** o separador `--` — pnpm repassa o `--` literal ao playwright e a flag vira file pattern ("No tests found"). Specs intactas (o `setup` não foi removido — virou dev-only, já documentado como prewarm dev-only); sem migration/access/Consent/UI. **Medição pós-merge (main, run 31418206512):** job e2e 435 s → **326 s (shard 1) / 369 s (shard 2)** — shard 2 = fixo 68 s + build 176 s + testes 124 s (a fase de testes caiu de 196 s seriais para 124 s — metade da suite por runner + cadeia removida; o shard 2 ainda pagou retry da flake conhecida de `campaignMunicipalities`; shard 1: testes ~82 s); gate completo do pipeline (checks) ~7,5 → ~6,4 min. Build passou a dominar o shard — gatilho da intenção (opção B, artefato compartilhado) reavaliável quando os testes encolherem mais. Intenção [`ci-e2e-paralelizar-job.md`](plans/ci-e2e-paralelizar-job.md); impl [`ci-e2e-paralelizar-job-impl.md`](plans/ci-e2e-paralelizar-job-impl.md).

View file

@ -0,0 +1,55 @@
---
id: D10
depends:
- 596
serializes: []
priority: P3
model: composer-2.5
model-local: deepseek-v4-flash-high
---
# Escala/DRY pós-D9: auto-cura de leftovers de consent/supporter + ABBA do shared-write do lease de consent
Lote de engenharia triado pelo `capture-review-debts` da sessão D9 (#596). Só testes/helpers; zero `src/` e zero migration.
## F1 — Self-heal de renames órfãos de consent (expensive_lock, unicidade)
**Problema:** uma run abortada no meio de um fixture de rename (kill do worker, restart do Postgres, timeout em cascata) deixa a linha canônica renomeada (`temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`, `temporarily-unkeyed-<id>`) e o snapshot perdido. O run seguinte: o fixture snapshotta a key canônica como ausente, cria uma linha nova, e o rename do teste bate no **UNIQUE** (`Valor deve ser único`) — a poluição é **auto-sustentada** (observado na sessão D9: 1 restart do container → 2–3 falhas por run até a limpeza manual). Mesma classe: contatos/supporters órfãos de runs abortados causam o conflito transitório 'Esta pessoa já está cadastrada como apoiador neste município' (1/14 nos runs da sessão).
**Direção:** os fixtures de consent (`withMutableConsentFixture`/`withMissingInviteConsentFixture`) e o `campaignFixtures.cleanup()` reconhecem e removem renames órfãos das keys estáveis no início do fixture (limpeza determinística por lista explícita dos nomes temporários + padrão `temporarily-unkeyed-%`), e o cleanup de supporter faz o mesmo para contatos/supporters órfãos. A decisão exata (lista vs padrão vs re-snapshot por id) fica para o impl plan — o aceite é: uma run abortada nunca mais envenena o run seguinte.
## F2 — ABBA latente do shared-write no lease de consent (expensive_lock)
**Problema:** `withLeasedConsent` (e o `withInviteConsent` de `campaignInviteUi`) segura o lease **shared** enquanto a operation **escreve** linhas do domínio de invite (redeem/consume) — a escrita segura row locks de invite; um holder exclusivo de consent (fixtures de `campaignInvite`) que escreve invites segura os row locks e espera o exclusivo → **deadlock ABBA** (consent-shared ↔ invite-row) — observado na sessão D9: cascata de 43–45 timeouts sob 6 workers (o lease exclusivo da Onda0 alargou a superfície; o revert para shared mitigou, mas o ABBA pré-existente permanece latente). O `campaignSupporter:95` tem o mesmo padrão (janela shared + operation que faz upsert de contato).
**Direção:** operations escritoras não devem rodar sob lease **shared** de consent — ou o lease sobe para exclusivo quando a operation escreve (decisão por contrato: `withLeasedConsent` ganha variante para leitura vs escrita), ou as escritas saem do escopo do lease shared. A ordem canônica (consent → invite) e o FIFO do advisory lock devem ser auditados para eliminar o ciclo. Decisão no impl plan; o aceite é: a cascata de timeouts não reproduz sob 6 workers + carga.
## Fases verificáveis
1. **F1** — self-heal de renames; repro: plantar um rename órfão, rodar a suíte 2×, provar a auto-cura; gates.
2. **F2** — contrato de leitura/escrita do `withLeasedConsent`; repro: 6 arquivos da key sob carga ×N sem cascata; suíte completa.
## Rabbit holes / Não escopo
- Mecanismo de lease (advisory lock) — canônico, fora.
- Orçamento de 15s do vitest (documentado na `vitest.config.mts`) — fora (gatilho: recorrência de cascatas com pool ativo).
- 4ª key da Onda0 (`campanha-notificacoes-push`) sem lease — comentado; fora (gatilho: primeiro escritor do push key).
## Já resolvido no simplify/critique (não reabrir)
- Asserções de restore por snapshot do fixture (não leituras voláteis).
- Janela de `withMissingInviteConsentFixture` sob lease + re-verificação/re-delete do gap setup→janela.
- Down da Onda0 restaura ids originais; leases shared (revert do exclusivo).
- `ORDER BY id`/`sort: 'id'` nas leituras limit-1; core `withDatabaseLease`; `restoreConsentRows` compartilhado; `ensureInviteConsent` órfão removido.
## Explicitamente fora (com gatilho)
- **S3** — cascata de 15s sob carga extrema: gatilho = recorrência com pool ativo (medir antes de mexer no orçamento).
- **S6** — push key sem lease: gatilho = primeiro escritor da key.
- Poluição residual de contatos (702 linhas runID-scoped, inertes): coberta pela limpeza da F1.
## Aceite de engenharia
- [ ] Run abortado não envenena o run seguinte (F1)
- [ ] Cascata de timeouts não reproduz sob 6 workers (F2)
- [ ] Invariantes AGENTS/engineering-standards; só testes; sem migration

View file

@ -0,0 +1,100 @@
# Impl: Flake do consent restore do `testDatabaseLease` sob carga (race de fixtures paralelas)
Status: aprovado
Atualizado em: 2026-08-10
Issue: #596
Intenção: docs/plans/test-database-lease-consent-flake.md
Appetite restante: herdado (fix focado em testes; zero mudança em `src/`)
## Leitura da intenção
- **Outcome:** `pnpm test:int` estável sob carga; o teste "restores the exact configured consent after deletion and recreation" passa N execuções consecutivas com os arquivos suspeitos rodando em paralelo.
- **O que NÃO negociar:** nenhuma mudança em `src/` (código de produto); mecanismo de lease (advisory lock) intacto; outras fixtures/leads não entram no escopo.
- **O que reavaliar:** a hipótese "leituras `LIMIT 1` sem `ORDER BY` escolhem a linha errada quando existe duplicata" — a caracterização mostrou que **duplicata persistente é impossível** (`consent_key_idx` é UNIQUE, verificado no banco); a hipótese "algum escritor NÃO toma o lease" é parcialmente verdadeira: o único estado não serializado é a **janela de operação** de `withMissingInviteConsentFixture` (deliberadamente sem lease para o fail-closed ver a key ausente).
## Caracterização (fase 1, reproduzida)
Reprodução local: suíte int completa (81 arquivos, `maxWorkers = min(8, cores)`), 3 falhas em 8 runs — o teste exato da intenção:
```
AssertionError: expected { id: 552, …(4) } to match object { id: 527, …(2) }
❯ tests/int/testDatabaseLease.int.spec.ts:217:19
```
`before.id = 527`, `after.id = 552` (key/text idênticos; ids diferentes por run; banco self-heal — 1 linha por key ao final).
**Mecanismo:** o `after` (e `before`, no sentido oposto) é um `withInviteConsent` cujo loop faz `ensureLeasedConsent` (find→create sob lease **exclusivo**) e depois uma leitura sob lease **shared**. A janela de `withMissingInviteConsentFixture` (setup commita o DELETE → `operation()` **sem lease** → restore) deixa a key commitada-ausente por ms; nessa janela, escritores de outros arquivos (fixtures `withMutableConsentFixture` com snapshot `undefined`, `ensureInviteConsent` de specs paralelos) rodam legitimamente — criam uma linha efêmera, que o restore do fixture deleta e substitui pelo snapshot. Um `withInviteConsent` que caiu na janela retorna a linha efêmera (ex.: 552) e o assertion compara com o snapshot restaurado (527) → flake. O `ORDER BY` sozinho **não** corrige: no momento da leitura existe uma linha só.
**Fix mínimo que mata o mecanismo:** serializar a janela com o lease existente (precedente exato do `onda0Provision`, comentário "without the shared leases this test races that window"). Com a janela sob lease exclusivo, nenhum escritor/leitor de outra spec observa o estado ausente → nenhuma linha efêmera → o `after` lê sempre o snapshot restaurado.
**Conflito identificado:** o teste "creates a missing configured consent only once under concurrent ensures" (linha 146) **deliberadamente** roda 3× `ensureInviteConsent` DENTRO da janela (elas próprias adquirem o lease exclusivo → deadlock com uma janela leaseada). Resolvido em duas camadas: (1) o teste migra para **key/lease privada** (`ensure-race-<random>`), então sua janela (que permanece `serializeWindow: false`) não expõe mais a key estável a specs paralelas; (2) documenta-se o opt-out.
**Evidência da contraprova (2ª iteração):** após o fix inicial, 8 runs da suíte completa: o teste-alvo D9 passou em todos, mas 2 testes irmãos flakearam em 1 run com dois mecanismos adicionais:
- **`rolls back a failed missing-consent setup…` (703→712):** o mesmo mecanismo id, via a janela ainda sem lease da linha 146 — fixtures de outros arquivos (snapshot `undefined`) rodavam nela e seu cleanup `DELETE … OR key = X` matava a linha 703 criada pelo `before` de outra spec. Fechado pela key privada acima (a key estável nunca mais fica ausente na suíte).
- **`restores the configured consent and releases after a callback assertion failure` (text mismatch):** o `provisionOnda0ConsentAndPrivacy` (spec paralela, shared lease) atualiza legitimamente o texto da linha entre o `before`-read e o snapshot do fixture — o restore reproduz fielmente o snapshot, mas a asserção comparava com a leitura volátil. Corrigido: as asserções dos 3 testes de restore passam a referenciar o **snapshot capturado no callback do fixture** (o contrato real do restore), não leituras `before` separadas; o teste de rollback (sem restore) asserta id+key (texto não é contrato dele).
**Raiz definitiva (3ª iteração — reproduzida com só 2 arquivos: `testDatabaseLease` + `onda0Provision`):** o `removeOnda0ConsentAndPrivacyDb` (teste 2 da spec Onda0, **`DELETE WHERE key IN (4 keys estáveis)`**) rodava sob leases **shared** — shared não bloqueia os reads shared de outras specs, então a remoção deletava a linha canônica **no meio** do teste D9 (entre o restore do fixture e o `after`-read): o id restaurado sumia irreversivelmente e o `after`-ensure recriava com id novo. Nem o lease exclusivo resolve esse gap (a remoção é um escritor legítimo). Fix: (a) a spec Onda0 passou para **leases exclusivos** (`withTestDatabaseLease` exportado do helper) — provision e remoção serializam contra todos os usuários de consent; (b) o teste de down **restaura as linhas com os ids originais** dentro da mesma janela exclusiva (invisível para as outras specs) — a key estável nunca fica ausente e nunca troca de id no meio da suíte; o SQL do down continua exercitado (DELETE + count-0 dentro da janela). `ensureInviteConsent` ficou órfão e foi removido (knip).
## Abordagem recomendada
```mermaid
flowchart LR
A[withMissingInviteConsentFixture] --> B[setup lease: snapshot + DELETE + commit]
B --> C{serializeWindow?}
C -->|true default| D[acquire restore-lease EXCLUSIVE]
D --> E[operation roda DENTRO do lease]
E --> F[beforeRestoreLeaseAcquire?]
F --> G[restore no mesmo lease: DELETE all + INSERT snapshot]
G --> H[release]
C -->|false linha 146| I[operation sem lease — comportamento atual]
I --> J[acquire restore-lease]
J --> G
```
**Opções consideradas:** A (serializar a janela sob lease exclusivo + opt-out) | B (remover o create de `withLeasedConsent`) | C (só `ORDER BY`/invariante de unicidade)
**Recomendação:** A — mata o mecanismo na raiz; preserva todos os testes; segue o precedente `onda0Provision` citado na intenção.
**Rejeitadas:** B porque o bootstrapping da linha em DB fresco/ausência pós-fault depende do create no loop (removê-lo faria `withInviteConsent` rodar quente até timeout quando a key fica ausente pelo fault-test da linha 295); C porque a duplicata é impossível (UNIQUE `consent_key_idx` verificado) e o `ORDER BY` não muda leitura de linha única.
**Descoberta da contraprova (4ª iteração — deadlock):** com a spec Onda0 sob leases **exclusivos**, a suíte passou a cascatear ~50% das vezes (43–45 timeouts de 15s) sob 6 workers — capturado por poller de `pg_locks`: um detentor **shared** `idle in transaction` por minutos + fila de exclusivos. É um **ABBA**: o ONDA0-exclusivo (novo holder) entra no ciclo `ONDA0(exclusivo) → campaignInviteUi(shared + write de invite dentro do lease) → campaignInvite(row-lock de invite + espera do exclusivo) → ONDA0`. O lease exclusivo não era o fix necessário — o **restore-com-ids** era — e o exclusivo alargou a superfície de deadlock. **Revertido para shared** (como pré-fix; o restore-com-ids + as janelas leaseadas + as asserções por snapshot fecham o flake sem adicionar holders exclusivos). Pós-revert: 6 arquivos ×13/14 verdes (1 falha transitória pré-existente de supporter-conflict, auto-curável, fora do escopo) e **suíte completa ×5/5 verdes**. A cascata de timeouts por carga extrema + o conflito de supporter ficam registrados como débitos (o orçamento de 15s é conhecido da vitest.config.mts).
### Componentes / mudanças (só testes — nenhum `src/`)
- **`MissingInviteConsentFixtureFaults`** (`tests/helpers/testDatabaseLease.ts`): novo campo `serializeWindow?: boolean` (default `true`).
- **`withMissingInviteConsentFixture`** (`tests/helpers/testDatabaseLease.ts`): quando `serializeWindow`, o restore-lease (exclusivo) é adquirido **antes** da `operation()` e o restore roda no mesmo lease — sem janela sem-lease observável; `beforeRestoreLeaseAcquire` continua entre operation e restore (se lançar, rollback do lease sem restore — mesmo outcome observável de hoje); fault hooks do setup intactos. Quando `false`, comportamento atual (janela sem lease).
- **Leituras determinísticas** (`ORDER BY "id"` nos dois snapshot SELECTs; `sort: 'id'` nos `payload.find` de `ensureLeasedConsent`/`withLeasedConsent`) — defesa em profundidade, barata, alinhada à hipótese (b) da intenção.
- **Migration:** nenhuma (só código de teste).
- **Access / Consent:** nenhuma (testes).
### Testes
- **Novo teste** em `testDatabaseLease.int.spec.ts`: "blocks other consent users while the missing-consent operation window is open" — dentro da `operation()` (janela leaseada), inicia `startTestDatabaseLeaseAcquisition(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY)` e asserta via `waitForAdvisoryLockWaiter` + `expectExactWaiter` que o escritor concorrente fica bloqueado (ExclusiveLock) até a janela fechar; então libera (try/finally). Usa só ferramentas existentes do arquivo.
- **Linha 146** ("creates a missing configured consent only once…"): passa `serializeWindow: false` com comentário explicando o opt-out deliberado (spec do ensure-durante-janela; mesma spec não pode ser afetada — arquivo sequencial).
- Fault tests (299–399): devem passar sem mudança — hooks preservados.
## Fases verificáveis
1. **Tracer/helpers** — restructure do fixture + opt-out linha 146 (key privada) + novo teste de serialização; `pnpm test:int` verde.
2. **ORDERS BYs + ONDA0** — leituras determinísticas; spec Onda0 sob leases exclusivos + restore de ids no down; `pnpm gate:fast` + knip limpo.
3. **Contraprova sob carga** — suíte int completa ×5 consecutivas sem falha; par mínimo (2 arquivos) ×8 verde; 6 arquivos da key ×13/14 verde (runs com restart externo do Postgres descartados por uptime do container; 1 falha transitória de supporter-conflict pré-existente).
## Rabbit holes / Não escopo (engenharia)
- Audit de todos os escritores não-leaseados (`submitWhatsapp` whatsapp-key, `campaignSupporter` vote-intention delete): mesmas keys só no próprio arquivo — sem race cross-file — ficam para follow-up se surgirem (não poluir o escopo).
- Invariante de unicidade no restore (hipótese c da intenção): impossível de disparar com `consent_key_idx` UNIQUE — não implementar, documentar por quê.
- Mudanças no mecanismo de lease, `src/`, ou outras fixtures.
## Riscos e mitigação
- **Deadlock janela × operation auto-leaseada:** mitigado pelo opt-out `serializeWindow: false` na linha 146 (única operation que adquire o lease sozinha).
- **Bloqueio extra durante janelas:** as janelas são checks fail-closed curtos (ações de ms); o precedente `onda0Provision` já bloqueia o mesmo período — sem orçamento de 15s em risco.
- **Semântica dos fault hooks alterada:** hooks preservados; os testes de fault (295–399) validam o contrato — rodar junto na fase 1.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (suíte estável sob carga; teste-alvo passa em execuções consecutivas)
- [x] Invariantes AGENTS/engineering-standards (só teste; sem DB de prod; sem migration)
- [x] Testes de domínio previstos: novo teste de serialização + suíte int completa + contraprova sob carga
- [x] Nenhuma mudança em `src/` (fora de escopo da intenção respeitado)
Self-score decision-quality: 5/5 (decisões caras com rejeitadas; appetite respeitado; rabbit holes nomeados; depth check — reusa `beginTestDatabaseLease`/`waitForAdvisoryLockWaiter`; aceite de produto mantido).

View file

@ -14,6 +14,16 @@ export type MissingInviteConsentFixtureFaults = {
beforeSnapshotSetup?: () => Promise<void>
consentKey?: string
leaseKey?: string
/**
* When true (default), the operation window runs under the exclusive lease:
* parallel spec files can never observe the committed-absent state, so their
* ensure-then-read helpers cannot create an ephemeral row that the restore
* deletes out from under them (D9 flake). Set to false only when the
* operation deliberately exercises concurrent consent writers during the
* window (see the "creates a missing configured consent only once under
* concurrent ensures" test).
*/
serializeWindow?: boolean
}
type LeaseTransaction = {
@ -42,7 +52,7 @@ export type WaitingAdvisoryLock = {
virtualTransaction: string
}
type ConsentSnapshot = {
export type ConsentSnapshot = {
id: number
key: string | null
text: Consent['text']
@ -50,6 +60,34 @@ type ConsentSnapshot = {
created_at: Date
}
/**
* Restores consent rows captured before a fixture deleted them, keeping their
* original ids so parallel spec files never observe the rows absent or with a
* new id (D9 flake). Shared by the consent fixtures and the Onda0 down test.
*/
export const restoreConsentRows = async (
execute: (query: ReturnType<typeof sql>) => Promise<unknown>,
rows: readonly ConsentSnapshot[],
): Promise<void> => {
for (const row of rows) {
await execute(sql`
INSERT INTO "consent" ("id", "key", "text", "updated_at", "created_at")
VALUES (
${row.id},
${row.key},
${row.text},
${row.updated_at},
${row.created_at}
)
ON CONFLICT ("id") DO UPDATE SET
"key" = EXCLUDED."key",
"text" = EXCLUDED."text",
"updated_at" = EXCLUDED."updated_at",
"created_at" = EXCLUDED."created_at"
`)
}
}
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'
@ -321,12 +359,14 @@ export const acquireSharedTestDatabaseLease = async (
leaseKey: string,
): Promise<TestDatabaseLease> => beginTestDatabaseLease(payload, leaseKey, 'shared')
const withTestDatabaseLease = async <Result>(
const withDatabaseLease = async <Result>(
payload: Payload,
leaseKey: string,
acquire: (payload: Payload, leaseKey: string) => Promise<TestDatabaseLease>,
failureMessage: string,
operation: () => Promise<Result>,
): Promise<Result> => {
const lease = await acquireTestDatabaseLease(payload, leaseKey)
const lease = await acquire(payload, leaseKey)
let operationError: unknown
try {
return await operation()
@ -338,44 +378,38 @@ const withTestDatabaseLease = async <Result>(
await lease.release()
} catch (releaseError) {
if (operationError !== undefined) {
throw combineErrors(
operationError,
releaseError,
'The leased operation and database lease release both failed.',
)
throw combineErrors(operationError, releaseError, failureMessage)
}
throw releaseError
}
}
}
const withTestDatabaseLease = async <Result>(
payload: Payload,
leaseKey: string,
operation: () => Promise<Result>,
): Promise<Result> =>
withDatabaseLease(
payload,
leaseKey,
acquireTestDatabaseLease,
'The leased operation and database lease release both failed.',
operation,
)
export const withSharedTestDatabaseLease = async <Result>(
payload: Payload,
leaseKey: string,
operation: () => Promise<Result>,
): Promise<Result> => {
const lease = await acquireSharedTestDatabaseLease(payload, leaseKey)
let operationError: unknown
try {
return await operation()
} catch (error) {
operationError = error
throw error
} finally {
try {
await lease.release()
} catch (releaseError) {
if (operationError !== undefined) {
throw combineErrors(
operationError,
releaseError,
'The shared leased operation and database lease release both failed.',
)
}
throw releaseError
}
}
}
): Promise<Result> =>
withDatabaseLease(
payload,
leaseKey,
acquireSharedTestDatabaseLease,
'The shared leased operation and database lease release both failed.',
operation,
)
export const ensureLeasedConsent = async (
payload: Payload,
@ -386,6 +420,7 @@ export const ensureLeasedConsent = async (
collection: 'consent',
where: { key: { equals: consentKey } },
limit: 1,
sort: 'id',
depth: 0,
})
if (existing.docs[0]) return existing.docs[0]
@ -416,6 +451,7 @@ export const withLeasedConsent = async <Result>(
collection: 'consent',
where: { key: { equals: consentKey } },
limit: 1,
sort: 'id',
depth: 0,
})
if (!configured.docs[0]) {
@ -441,12 +477,6 @@ export const withLeasedConsent = async <Result>(
}
}
export const ensureInviteConsent = async (payload: Payload): Promise<Consent> =>
ensureLeasedConsent(payload, {
consentKey: CAMPAIGN_INVITE_CONSENT_KEY,
leaseKey: CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
})
export const withInviteConsent = async <Result>(
payload: Payload,
operation: (consent: Consent) => Promise<Result>,
@ -464,6 +494,7 @@ export const withMissingInviteConsentFixture = async <Result>(
): Promise<Result> => {
const consentKey = faults.consentKey ?? CAMPAIGN_INVITE_CONSENT_KEY
const leaseKey = faults.leaseKey ?? CAMPAIGN_INVITE_CONSENT_LEASE_KEY
const serializeWindow = faults.serializeWindow ?? true
const setupLease = await beginTestDatabaseLease(payload, leaseKey)
let snapshot: ConsentSnapshot | undefined
let setupError: unknown
@ -474,6 +505,7 @@ export const withMissingInviteConsentFixture = async <Result>(
SELECT "id", "key", "text", "updated_at", "created_at"
FROM "consent"
WHERE "key" = ${consentKey}
ORDER BY "id"
LIMIT 1
`),
)[0]
@ -513,6 +545,44 @@ export const withMissingInviteConsentFixture = async <Result>(
}
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).
// `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
// Close the gap between the setup's committed delete and this acquisition:
// a writer that queued during the setup (advisory locks grant FIFO) can
// re-create the row here, which would break the fail-closed operation and
// make the restore delete a live row. Re-verify committed state while we
// hold the window lease and delete again if needed.
if (windowLease) {
const recreated = await payload.find({
collection: 'consent',
where: { key: { equals: consentKey } },
limit: 1,
sort: 'id',
depth: 0,
})
if (recreated.docs[0]) {
await payload.delete({
collection: 'consent',
where: { key: { equals: consentKey } },
depth: 0,
})
}
}
let result: Result | undefined
let operationError: unknown
try {
@ -525,29 +595,26 @@ export const withMissingInviteConsentFixture = async <Result>(
let restoreLease: Awaited<ReturnType<typeof beginTestDatabaseLease>> | undefined
try {
await faults.beforeRestoreLeaseAcquire?.()
restoreLease = await beginTestDatabaseLease(payload, leaseKey)
restoreLease = windowLease ?? (await beginTestDatabaseLease(payload, leaseKey))
} catch (error) {
cleanupError = error
if (windowLease && !restoreLease) {
try {
await windowLease.rollback()
} catch (rollbackError) {
cleanupError = addFailure(
cleanupError,
rollbackError,
'The missing consent fixture restore acquisition and window lease rollback both failed.',
)
}
}
}
if (restoreLease) {
try {
await restoreLease.transaction.execute(sql`DELETE FROM "consent" WHERE "key" = ${consentKey}`)
if (snapshot) {
await restoreLease.transaction.execute(sql`
INSERT INTO "consent" ("id", "key", "text", "updated_at", "created_at")
VALUES (
${snapshot.id},
${snapshot.key},
${snapshot.text},
${snapshot.updated_at},
${snapshot.created_at}
)
ON CONFLICT ("id") DO UPDATE SET
"key" = EXCLUDED."key",
"text" = EXCLUDED."text",
"updated_at" = EXCLUDED."updated_at",
"created_at" = EXCLUDED."created_at"
`)
await restoreConsentRows((query) => restoreLease.transaction.execute(query), [snapshot])
}
} catch (error) {
cleanupError = error
@ -591,6 +658,7 @@ export const withMutableConsentFixture = async <Result>(
SELECT "id", "key", "text", "updated_at", "created_at"
FROM "consent"
WHERE "key" = ${CAMPAIGN_INVITE_CONSENT_KEY}
ORDER BY "id"
LIMIT 1
`),
)[0]
@ -622,21 +690,7 @@ export const withMutableConsentFixture = async <Result>(
WHERE "key" = ${CAMPAIGN_INVITE_CONSENT_KEY}
AND "id" <> ${snapshot.id}
`)
await lease.transaction.execute(sql`
INSERT INTO "consent" ("id", "key", "text", "updated_at", "created_at")
VALUES (
${snapshot.id},
${snapshot.key},
${snapshot.text},
${snapshot.updated_at},
${snapshot.created_at}
)
ON CONFLICT ("id") DO UPDATE SET
"key" = EXCLUDED."key",
"text" = EXCLUDED."text",
"updated_at" = EXCLUDED."updated_at",
"created_at" = EXCLUDED."created_at"
`)
await restoreConsentRows((query) => lease.transaction.execute(query), [snapshot])
} else if (createdConsentID !== undefined) {
await lease.transaction.execute(sql`
DELETE FROM "consent"

View file

@ -19,7 +19,9 @@ import {
CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY,
restoreConsentRows,
withSharedTestDatabaseLease,
type ConsentSnapshot,
} from '../helpers/testDatabaseLease'
let payload: Payload
@ -38,9 +40,19 @@ describe('Onda 0 provision (integration)', () => {
})
it('upserts consent keys and publishes privacy-policy idempotently', async () => {
// Shared leases on every stable consent key: parallel spec files exercise
// "consent missing" fail-closed paths by deleting these rows under an
// EXCLUSIVE lease — without the shared leases this test races that window.
// 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(
@ -81,7 +93,7 @@ describe('Onda 0 provision (integration)', () => {
await provisionOnda0ConsentAndPrivacyDb(db)
const consentRows = await db.execute(sql`
SELECT "key"
SELECT "id", "key", "text", "updated_at", "created_at"
FROM "consent"
WHERE "key" IN (${sql.join(
ONDA0_CONSENT_KEY_LIST.map((key) => sql`${key}`),
@ -119,6 +131,16 @@ describe('Onda 0 provision (integration)', () => {
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[],
)
},
),
),

View file

@ -3,18 +3,20 @@
import { getPayload, type Payload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import type { Consent } from '@/payload-types'
import config from '@/payload.config'
import {
acquireSharedTestDatabaseLease,
acquireTestDatabaseLease,
CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
ensureInviteConsent,
ensureLeasedConsent,
startTestDatabaseLeaseAcquisition,
waitForAdvisoryLockWaiter,
withInviteConsent,
withMissingInviteConsentFixture,
withMutableConsentFixture,
withSharedTestDatabaseLease,
type TestDatabaseLease,
} from '../helpers/testDatabaseLease'
let payload: Payload
@ -144,26 +146,79 @@ describe('test database lease', () => {
})
it('creates a missing configured consent only once under concurrent ensures', async () => {
await withMissingInviteConsentFixture(payload, async () => {
const ensured = await Promise.all([
ensureInviteConsent(payload),
ensureInviteConsent(payload),
ensureInviteConsent(payload),
])
const configured = await withSharedTestDatabaseLease(
payload,
CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
() =>
// Private key/lease: this test deliberately exercises ensure-then-create
// racing the missing window, so its window stays unleased
// (serializeWindow: false — the ensures lease themselves). Running the
// race on a private key keeps the stable invite row untouched: an
// unleased window on a shared key would let other files' fixtures observe
// the committed-absent state and delete the row out from under them.
const raceKey = `ensure-race-${Date.now()}-${Math.random()}`
const raceLeaseKey = `ensure-race-lease-${Date.now()}-${Math.random()}`
await withMissingInviteConsentFixture(
payload,
async () => {
const ensured = await Promise.all([
ensureLeasedConsent(payload, { consentKey: raceKey, leaseKey: raceLeaseKey }),
ensureLeasedConsent(payload, { consentKey: raceKey, leaseKey: raceLeaseKey }),
ensureLeasedConsent(payload, { consentKey: raceKey, leaseKey: raceLeaseKey }),
])
const configured = await withSharedTestDatabaseLease(payload, raceLeaseKey, () =>
payload.find({
collection: 'consent',
where: { key: { equals: 'lideranca-autopreenchimento' } },
where: { key: { equals: raceKey } },
depth: 0,
limit: 10,
}),
)
)
expect(new Set(ensured.map((document) => document.id))).toHaveLength(1)
expect(configured.docs).toHaveLength(1)
expect(new Set(ensured.map((document) => document.id))).toHaveLength(1)
expect(configured.docs).toHaveLength(1)
},
{ consentKey: raceKey, leaseKey: raceLeaseKey, serializeWindow: false },
)
})
it('blocks other consent users while the missing-consent operation window is open', async () => {
const before = await withInviteConsent(payload, async (consent) => consent)
let writerAcquisition: Promise<TestDatabaseLease> | undefined
try {
await withMissingInviteConsentFixture(payload, async () => {
let writerEntered = false
const pendingWriter = startTestDatabaseLeaseAcquisition(
payload,
CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
)
writerAcquisition = pendingWriter.acquisition.then((lease) => {
writerEntered = true
return lease
})
const writerPID = await pendingWriter.backendPID
const waiting = await waitForAdvisoryLockWaiter(payload, {
key: `test:${CAMPAIGN_INVITE_CONSENT_LEASE_KEY}`,
mode: 'ExclusiveLock',
waiterPID: writerPID,
})
expectExactWaiter(waiting, writerPID, 'ExclusiveLock')
expect(writerEntered).toBe(false)
})
} finally {
// Release the waiter lease even when an assertion above fails, so the
// pending acquisition never blocks parallel spec files until teardown.
if (writerAcquisition) {
const writer = await writerAcquisition
await writer.release()
}
}
const after = await withInviteConsent(payload, async (consent) => consent)
// id+key only: a concurrent legitimate writer (e.g. the Onda0 provision)
// may update the row's text between these reads — the window's contract is
// that no exclusive writer interleaves, not that the text is frozen.
expect(after).toMatchObject({
id: before.id,
key: before.key,
})
})
@ -181,9 +236,13 @@ describe('test database lease', () => {
})
it('restores the exact configured consent after deletion and recreation', async () => {
const before = await withInviteConsent(payload, async (consent) => consent)
// The reference is the fixture's own snapshot (the configured consent the
// callback receives), not a separate read: a concurrent legitimate writer
// (e.g. the Onda0 provision) may update the row's text between reads, and
// the restore faithfully reproduces whatever the snapshot captured.
let before: Consent | undefined
await withMutableConsentFixture(payload, async (consent) => {
before = consent
await payload.delete({ collection: 'consent', id: consent.id })
await payload.create({
collection: 'consent',
@ -214,6 +273,7 @@ describe('test database lease', () => {
})
const after = await withInviteConsent(payload, async (consent) => consent)
if (!before) throw new Error('The mutable consent fixture must have provided a snapshot.')
expect(after).toMatchObject({
id: before.id,
key: before.key,
@ -234,9 +294,16 @@ describe('test database lease', () => {
}),
)
const callbackFailure = new Error('intentional callback assertion failure')
// The reference is the fixture's own snapshot (what the callback received),
// not the `before` read: a concurrent legitimate writer (e.g. the Onda0
// provision) may update the row's text between reads, and the restore
// faithfully reproduces whatever the snapshot captured. `before` still
// pins the existence state (restored vs created-then-deleted).
let snapshot: Consent | undefined
await expect(
withMutableConsentFixture(payload, async (consent) => {
snapshot = consent
await payload.update({
collection: 'consent',
id: consent.id,
@ -278,14 +345,14 @@ describe('test database lease', () => {
limit: 1,
}),
)
if (before.docs[0]) {
expect(after.docs).toHaveLength(before.docs.length)
if (after.docs[0]) {
if (!snapshot) throw new Error('The mutable consent fixture must have provided a snapshot.')
expect(after.docs[0]).toMatchObject({
id: before.docs[0].id,
key: before.docs[0].key,
text: before.docs[0].text,
id: snapshot.id,
key: snapshot.key,
text: snapshot.text,
})
} else {
expect(after.docs).toHaveLength(0)
}
const subsequent = await acquireTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY)
@ -295,6 +362,11 @@ describe('test database lease', () => {
it('aggregates callback and restore lease acquisition failures in original order', async () => {
const callbackFailure = new Error('intentional callback failure')
const restoreAcquisitionFailure = new Error('intentional restore acquisition failure')
// Private key: this fault skips the restore, so the committed-absent state
// would leak for the rest of the run on a shared key (id churn in every
// other file). A private key keeps the leak invisible.
const faultKey = `restore-fault-${Date.now()}-${Math.random()}`
const faultLeaseKey = `restore-fault-lease-${Date.now()}-${Math.random()}`
const failure = await withMissingInviteConsentFixture(
payload,
@ -302,6 +374,8 @@ describe('test database lease', () => {
throw callbackFailure
},
{
consentKey: faultKey,
leaseKey: faultLeaseKey,
beforeRestoreLeaseAcquire: async () => {
throw restoreAcquisitionFailure
},
@ -357,10 +431,12 @@ describe('test database lease', () => {
expect(failure).toBeInstanceOf(AggregateError)
expect((failure as AggregateError).errors).toEqual([releaseFailure, rollbackFailure])
const after = await withInviteConsent(payload, async (consent) => consent)
// The rollback preserves the exact row; the text is not part of the
// contract here (no restore ran, and a concurrent legitimate writer such
// as the Onda0 provision may update it between reads).
expect(after).toMatchObject({
id: before.id,
key: before.key,
text: before.text,
})
const subsequent = await acquireTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY)