From bf25100ed502b30395d1f52f61fc9684a081322c Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 13 Aug 2026 14:18:54 -0300 Subject: [PATCH 1/3] =?UTF-8?q?chore(OPS45):=20e2e=20fixtures=20=E2=80=94?= =?UTF-8?q?=20purge=20de=20res=C3=ADduo=20no=20claim=20de=20munic=C3=ADpio?= =?UTF-8?q?=20+=20robustez=20dos=20int=20specs=20(Closes=20#723)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O cleanup do campaignE2EFixtures estava completo em runs normais (proxy auto-own cobre contact/stateDeputy); o lixo vinha de runs crashados, cujo cleanup nunca roda. O claim e2e agora purga resíduo como o int já fazia, e a purga ganhou a classe dobradinha (stateDeputy vinculados via municipality.stateDeputies + fichas órfãs com ref-check incluindo stateDeputy — FK ON DELETE RESTRICT). peopleList.int.spec.ts passa a varrer todas as páginas (lista global de 25/pág) com dedupe; personDelete/peopleList usam personName() nos nomes fixos, imunes ao invariante global de nome de dobradinha. --- ...5-e2e-fixtures-residuo-banco-teste-impl.md | 146 +++++++++++++++ .../ops46-infra-teste-e2e-local-allocator.md | 75 ++++++++ tests/e2e/fixtures/campaignE2EFixtures.ts | 6 + tests/helpers/campaignFixtures.ts | 177 ++++++++++++------ tests/int/campaignFixtures.int.spec.ts | 91 +++++++++ tests/int/peopleList.int.spec.ts | 121 ++++++++---- tests/int/personDelete.int.spec.ts | 16 +- 7 files changed, 537 insertions(+), 95 deletions(-) create mode 100644 docs/plans/ops45-e2e-fixtures-residuo-banco-teste-impl.md create mode 100644 docs/plans/ops46-infra-teste-e2e-local-allocator.md diff --git a/docs/plans/ops45-e2e-fixtures-residuo-banco-teste-impl.md b/docs/plans/ops45-e2e-fixtures-residuo-banco-teste-impl.md new file mode 100644 index 00000000..f608473a --- /dev/null +++ b/docs/plans/ops45-e2e-fixtures-residuo-banco-teste-impl.md @@ -0,0 +1,146 @@ +# Impl: e2e — campaignE2EFixtures deixa lixo no banco de teste (contamina int specs sob resíduo) + +Status: aprovado +Atualizado em: 2026-08-13 +Issue: #723 +Intenção: body da Issue (sem `Plano:` linkado — o body é a spec) +Appetite restante: herdado (chore P2 — cleanup + robustez de specs) + +## Leitura da intenção + +- **Outcome:** runs e2e (E2E_PROD) não podem deixar lixo que quebre int specs + pré-existentes; o cleanup do `campaignE2EFixtures` deve ser investigado e, + se necessário, os nomes fixos dos int specs viram únicos por run. +- **O que NÃO negociar:** nenhum produto aqui — infra de teste. Invariantes a + manter: nunca tocar prod, allocator de município (sequência Postgres) como + contrato de exclusividade, regra de referência LGPD para deletar ficha + órfã (mesma do `removeSupporterData` / D10 F1), nomes de spec continuam + schema-válidos (letters+spaces/hyphens — `personName` existe para isso). +- **O que reavaliar:** a hipótese do body "owned de stateDeputy/contact via + fixtures.payload.create?" — investigada e **falsa**: contact/stateDeputy + criados pelo proxy SÃO auto-owned e descobertos pelo runID no cleanup + normal. O vazamento real é de runs **crashados** (cleanup nunca roda), a + mesma classe que os fixtures int já defendem com `purgeMunicipalityResidue` + no claim — que o `claimMunicipality` e2e não faz. + +## Diagnóstico (investigação da Issue) + +1. **Cleanup e2e em run normal está completo** — proxy auto-own de + `stateDeputy`/`contact` (`isOwnedCollection` + `discoverOwnedRows` por + runID em name/email + derivação de stateDeputy via `contact in`). Resíduo + observado ('Ciclo Liderança-', 'Coordenadora C128-', + 'deputado-') só nasce quando o run morre antes do `finally` do + fixture (timeout/worker morto). +2. **Classes de resíduo e2e** (verificado nas specs): dobradinhas + fichas + vinculadas a município claimado (`campaignPeople` 183–237, 457–559 — + `municipality.stateDeputies`), contas staff + fichas ('Coordenadora C128'), + e lideranças/supporters/etc. (já purgáveis por município). +3. **Mecanismo do `peopleList.int.spec.ts`:** a lista de pessoas é GLOBAL e + paginada (25/página, ordenada por nome); o advisor nasce como + 'Usuário advisor-' (ordena tarde). Com ≥25 linhas residuais, a + ficha do advisor cai da página 1 → "advisorRow não achado" (maria/ana, + que ordenam cedo, seguem visíveis — exatamente o sintoma descrito). +4. **Mecanismo do `personDelete.int.spec.ts`:** nome fixo 'Maria de Jesus' + + invariante global de nome de dobradinha (`assertStateDeputyNameAvailable`, + `src/utilities/stateDeputy/nameInvariant.ts`) — dobradinha residual com o + mesmo nome bloqueia o create do spec. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[claimMunicipality e2e] --> B[purgeMunicipalityResidue
+ classe dobradinha] + C[peopleList.int] --> D[presença via todas as páginas] + E[personDelete + peopleList] --> F[personName p/ nomes fixos] + B --> G[resíduo e2e não acumula
nem para int nem para e2e] +``` + +**Opções consideradas:** + +- **A — purge no claim (espelhar int) + estender `purgeMunicipalityResidue` + para a classe dobradinha + robustez dos 2 int specs.** O resíduo e2e + desaparece no próximo claim (contrato do allocator garante dono único), os + int specs deixam de depender de estado global. +- **B — só nomes únicos nos int specs.** Não resolve o peopleList (a poluição + de página não é por nome, é por volume) nem a acumulação do resíduo e2e. +- **C — sweep global por padrão de nome no setup e2e.** Fragil e perigoso: + contas staff usam emails `@example.com` como os runs vivos — mataria runs + paralelos. + +**Recomendação:** **A** — porque ataca a fonte (resíduo some no claim, +mesmo precedente do `getMunicipality` int), e a robustez dos specs fecha o +resto (resíduo não-purgeável: contas staff de run crashado — ~1–2 linhas por +crash, sem âncora de município e sem padrão seguro). A classe dobradinha é +purgeável porque SEMPRE está vinculada ao município claimado pelo próprio run. + +**Rejeitadas:** B sozinha (não cobre peopleList nem a acumulação); C (perigosa +— padrões coincidem com runs vivos). + +### Componentes / mudanças + +- **`purgeMunicipalityResidue`** (`tests/helpers/campaignFixtures.ts`): + estende a purga para a classe dobradinha — lê `municipality.stateDeputies` + do município claimado, deleta os `stateDeputy` residuais, deleta as fichas + órfãs (mesma checagem de referências do D10 F1: leadership/signature/ + subscription/supporter + `campaignUser.contact` — resíduo C99) e limpa o + campo `stateDeputies: []`. Seguro pelo contrato do allocator: nenhum run + vivo opera o município claimado (guard `testMunicipalityAllocatorConventions`). +- **`CampaignE2EOwnership.claimMunicipality`** (`tests/e2e/fixtures/campaignE2EFixtures.ts`): + chama `purgeMunicipalityResidue(this.rootPayload, municipality.id)` após o + find — espelha `getMunicipality` int (mesma sequência, mesmo contrato). +- **`peopleList.int.spec.ts`**: helper local `loadAllPeopleRows` (páginas + 1..totalPages) para as asserções de presença; facets seguem globais na + página 1 (já são calculadas sobre o conjunto scoped, pré-paginação). +- **`personDelete.int.spec.ts` / `peopleList.int.spec.ts`**: nomes fixos → + `fixtures.personName(prefix)` (schema-válido, único por run), vinculando o + nome nas asserções que o usam (`manifest?.contact.name`, `toMatchObject`). +- **Migration:** sem migration (teste/helpers apenas). +- **Access / Consent:** nenhum — fixture/helpers de teste; a checagem de + órfã usa o mesmo critério do fluxo de produção (fail-closed). +- **UI:** não há. + +### Dados → forma + +N/A (sem UI). + +## Fases verificáveis + +1. **Purga + claim e2e** — estender `purgeMunicipalityResidue`; ligar no + `claimMunicipality` e2e; `pnpm test:int -- campaignFixtures` (a spec de + invariante de contagem usa DB próprio) + gate unit (`testMunicipalityAllocatorConventions`). +2. **Int specs robustos** — `loadAllPeopleRows` em peopleList; `personName` + em personDelete + peopleList; `pnpm test:int` (arquivos-alvo). +3. **Gates** — `pnpm gate:fast`; e2e do `campaignPeople` em dev para provar o + purge no claim; `pnpm push` (débitos via capture-review-debts autônomo). + +## Rabbit holes / Não escopo (engenharia) + +- **Sweep de contas staff residuais** (sem âncora; padrão coincide com runs + vivos) — registrado como débito, não corrigido aqui. +- Migrar os outros specs com nomes fixos de liderança ('Maria da Silva' etc. + em campaignInvite/campaignLeadership) — liderança não tem invariante de + nome; o resíduo não as quebra (fora de escopo). +- `campaignFixturesRowCounts` usa DB dedicado — não afetado. +- **Ambiente da máquina (registrado como débito):** a coleção e2e local + quebra no load de `src/utilities/documents.ts` (`Cannot find module +'next/cache'` — ESM nativo não resolve subpath sem exports; `next/cache.js` + existe, `next/cache` não). Reproduz no main repo e sob Node 20/24.14/24.18 + — pré-existente ao OPS45, fora de escopo; a verificação e2e fica para o CI + do PR. + +## Riscos e mitigação + +- **Purga deletar linha de run vivo:** mitigado pelo contrato do allocator + (nenhum claim por slug fixo — guard unit existente) + checagem de + referências antes de deletar ficha órfã. +- **peopleList mais lento (paginação):** 25 linhas/página, poucos reads — + custo desprezível; semanticamente idêntico (presença global). +- **`purgeMunicipalityResidue` compartilhada int+e2e:** o e2e passa a pagar a + mesma purga que o int já paga; sem mudança de comportamento para o int + além da nova classe dobradinha (resíduo, por definição não referenciado). + +## Aceite de engenharia + +- [x] Aceite de produto da intenção coberto (cleanup investigado + nomes fixos únicos + resíduo não acumula) +- [x] Invariantes AGENTS/engineering-standards (allocator, D10 F1, personName) +- [x] Testes previstos: unit do allocator (já existente), int dos dois specs, e2e campaignPeople diff --git a/docs/plans/ops46-infra-teste-e2e-local-allocator.md b/docs/plans/ops46-infra-teste-e2e-local-allocator.md new file mode 100644 index 00000000..51fe33a1 --- /dev/null +++ b/docs/plans/ops46-infra-teste-e2e-local-allocator.md @@ -0,0 +1,75 @@ +# OPS46 — Infra de teste e2e: e2e local quebrado (next/cache) + allocator de município com wrap + +Status: rascunho +Atualizado em: 2026-08-13 +Issue: #? (a criar via agent:register) +Intenção: débitos do OPS45 (capture-review-debts autônomo) + +## Fase 1 — e2e local não coleta: `Cannot find module 'next/cache'` (máquina do humano) + +**Sintoma:** `pnpm exec playwright test --list` falha em todo spec — `Error: Cannot +find module '/node_modules/next/cache' imported from src/utilities/documents.ts`. +Reproduz no worktree OPS45, no main repo (`/home/fsolla/Code/teqo`) e sob Node +v20.20.2 / v24.14.0 / v24.18.0 — pré-existente ao OPS45 (não é do diff). + +**Mecanismo medido:** `import { revalidateTag } from 'next/cache'` (ESM, subpath +sem extensão) falha no resolver ESM nativo do Node — `next/package.json` NÃO tem +`exports`, então `next/cache` (arquivo `cache.js`) não resolve em ESM puro +(`node --input-type=module -e "import('next/cache')"` → ERR_MODULE_NOT_FOUND; +`import('next/cache.js')` → OK). O carregamento do graph do fixture e2e +(`src/payload.config.ts` → `documents.ts`) passa por esse import; o transpiler +do Playwright não reescreve bare specifier sem exports. CI passa (mesma versão +de next/playwright) — investigar o que difere no ambiente CI. + +**Ações prováveis:** descobrir o deltas CI vs máquina (versão de node do runner, +flags de resolução, `NODE_OPTIONS`, corepack) e/ou tornar o import resolvível +(registrar alias `next/cache` → `next/cache.js` no carregador do Playwright, +ou mover `revalidateTag` para um shim com subpath com exports). Evidência de +referência: reproduzir em docker com node 24 latest (mesmo do CI) para isolar. + +**Rabbit holes:** não é o OPS45; não mexer nos fixtures; não é npm registry. + +## Fase 2 — Allocator de município com wrap de módulo: colisão de claim deleta linhas vivas + +**Sintoma latente:** `nextval % municipalityCatalog.length` (435) na sequência +persistente compartilhada `campaign_fixture_municipality_alloc` (int + e2e). +A sequência nunca é resetada; após 435 claims no MESMO banco (DBs persistentes +de worktree acumulam — o problema do OPS45), dois runs concorrentes podem +receber o MESMO índice e cada um purga as linhas vivas do outro (o purge-on-claim +— int desde OPS19/D10 F1, e2e desde OPS45 — transforma a colisão em corrupção). + +**Ações prováveis:** registry de claims com geração (`index, generation`, +UNIQUE em index, `ON CONFLICT` retry) ou fail-loud no wrap; medir primeiro a +contagem real de claims por run completo (int + e2e) para dimensionar. + +**Rabbit holes:** não alterar o contrato de nomes/URLs; não tocar no +`testMunicipalityAllocatorConventions.unit.spec.ts` sem migrar os guards. + +## Já resolvido no OPS45 (não reabrir) + +- stateDeputy no ref-check de fichas órfãs + teste da forma compartilhada + (supporter+dobradinha) — `purgeMunicipalityResidue` não crasha mais. +- `stateDeputies: []` gated no resíduo (sem write por claim limpo). +- `loadAllPeopleRows` com loop auto-curável + dedupe por contactID. + +## Explicitamente fora (deste lote) + +- **Contas staff residuais** ('Coordenadora C128-') — sem âncora de + município e sem padrão seguro (emails `@example.com` coincidem com runs + vivos); absorvidas pela paginação do peopleList. Gatilho: se voltar a + quebrar um spec global, reavaliar sweep por created_at + ausência de joins. +- **`activity.tasks[].responsible` fora do ref-check** — FK SET NULL, + silencioso e auto-curável; shape improvável (atividade residual em município + não claimado referenciando ficha purgada). Gatilho: 2ª referência a Contact + fora do check. +- **personName letteriza o runID** (invisível ao discoverMarkedRoots) — + latente até um spec alimentar personName via action real (sem proxy). + Gatilho: o 1º uso de personName fora do `createContact`. +- **Import do módulo vitest no fixture e2e** — funciona hoje (hooks inertes + fora do runner); gatilho: vitest mudar o entry / quebrar o import. + +## Aceite de engenharia + +- [ ] Fase 1: `pnpm exec playwright test --list` coleta na máquina do humano +- [ ] Fase 2: sem colisão de claim após N wraps (teste de stress ou fail-loud) +- [ ] Guards: `testMunicipalityAllocatorConventions` verde após a mudança diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index f301373e..3f079712 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -9,6 +9,7 @@ import type { CampaignUser } from '../../../src/payload-types.js' import config from '../../../src/payload.config.js' import { withPayloadTransaction } from '../../../src/utilities/payloadTransaction.js' import { assertTestDatabase } from '../../helpers/assertTestDatabase.js' +import { purgeMunicipalityResidue } from '../../helpers/campaignFixtures.js' import { withInviteConsent } from '../../helpers/testDatabaseLease.js' import { test as base, expect } from './e2eTest.js' @@ -156,6 +157,11 @@ export class CampaignE2EOwnership { const municipality = found.docs[0] if (!municipality) throw new Error(`Seeded municipality "${slug}" not found — run migrations first.`) + // OPS45 — a crashed e2e run (worker killed before the cleanup `finally`) + // leaves rows behind; purge them on the fresh claim, mirroring the int + // `getMunicipality` contract. Safe by the allocator: no live run owns + // this municipality. + await purgeMunicipalityResidue(this.rootPayload, municipality.id) this.touchedMunicipalities.add(municipality.id) // The catalog name, not the row's: since B34+ the chips label from // `municipalityCatalog`, so a row an int spec renamed and left behind would diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 5b739014..44701969 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -233,11 +233,89 @@ const nextAllocatedMunicipalityIndex = async ( return value % catalogSize } +/** + * D10 F1 / OPS45: delete CONTACT rows an aborted run orphaned (no other join + * references them). Production `removeSupporterData` only anonymizes, so its + * reference rule is a subset of what a DELETE needs: every join that can hold + * a ficha must be checked — leadership/signature/subscription/supporter, the + * C99 account link (`campaignUser.contact`) and the `stateDeputy` FK + * (`ON DELETE RESTRICT`, so an unchecked deputy would make the delete throw). + * Reimplemented here so the fixture never runs production code under test. + */ +const deleteOrphanedResidueContacts = async ( + payload: Payload, + contactIDs: Array, +): Promise => { + const uniqueIDs = [...new Set(contactIDs.filter((id): id is number => id !== undefined))] + if (uniqueIDs.length === 0) return + const [leadershipRefs, signatureRefs, subscriptionRefs, supporterRefs, accountRefs, deputyRefs] = + await Promise.all([ + payload.find({ + collection: 'leadership', + where: { contact: { in: uniqueIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'signature', + where: { contact: { in: uniqueIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'subscription', + where: { contact: { in: uniqueIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'supporter', + where: { contact: { in: uniqueIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'campaignUser', + where: { contact: { in: uniqueIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'stateDeputy', + where: { contact: { in: uniqueIDs } }, + depth: 0, + pagination: false, + }), + ]) + const referenced = new Set() + for (const doc of [ + ...leadershipRefs.docs, + ...signatureRefs.docs, + ...subscriptionRefs.docs, + ...supporterRefs.docs, + ...accountRefs.docs, + ...deputyRefs.docs, + ]) { + const contactID = relationId(doc.contact) + if (contactID !== undefined) referenced.add(contactID) + } + const orphanContactIDs = uniqueIDs.filter((id) => !referenced.has(id)) + if (orphanContactIDs.length > 0) { + await payload.delete({ + collection: 'contact', + where: { id: { in: orphanContactIDs } }, + depth: 0, + }) + } +} + /** * 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). + * Also deletes the CONTACT rows those residue supporters orphaned (D10 F1) and + * the dobradinha residue (OPS45): deputies linked through + * `municipality.stateDeputies`, their orphaned fichas, and the field itself. */ export const purgeMunicipalityResidue = async ( payload: Payload, @@ -343,59 +421,50 @@ export const purgeMunicipalityResidue = async ( // 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, - }) - } + // join references them) so the residue stops accumulating. + await deleteOrphanedResidueContacts( + payload, + supporters.docs.map((doc) => relationId(doc.contact)), + ) + + // OPS45 — dobradinha residue: deputies linked through the freshly claimed + // municipality's `stateDeputies` belong to the crashed run that claimed it + // (the allocator contract rules out a live owner). Delete the deputies, + // the fichas they orphaned, and clear the field so a later claim starts + // clean. The deputy contacts are captured BEFORE the delete. + const claimed = await payload.findByID({ + collection: 'municipality', + id: municipalityID, + depth: 0, + select: { stateDeputies: true }, + overrideAccess: true, + }) + const residueDeputyIDs = relationIds(claimed?.stateDeputies ?? []) + if (residueDeputyIDs.length > 0) { + const deputies = await payload.find({ + collection: 'stateDeputy', + where: { id: { in: residueDeputyIDs } }, + depth: 0, + pagination: false, + select: { contact: true }, + overrideAccess: true, + }) + await payload.delete({ + collection: 'stateDeputy', + where: { id: { in: residueDeputyIDs } }, + depth: 0, + }) + await deleteOrphanedResidueContacts( + payload, + deputies.docs.map((doc) => relationId(doc.contact)), + ) + await payload.update({ + collection: 'municipality', + id: municipalityID, + data: { stateDeputies: [] }, + depth: 0, + overrideAccess: true, + }) } } diff --git a/tests/int/campaignFixtures.int.spec.ts b/tests/int/campaignFixtures.int.spec.ts index 6307eb4f..3da4032d 100644 --- a/tests/int/campaignFixtures.int.spec.ts +++ b/tests/int/campaignFixtures.int.spec.ts @@ -8,6 +8,8 @@ import config from '@/payload.config' import { createCampaignFixtures, purgeMunicipalityResidue, + relationId, + relationIds, withCampaignFixtures, } from '../helpers/campaignFixtures' import { withInviteConsent, withMutableConsentFixture } from '../helpers/testDatabaseLease' @@ -445,4 +447,93 @@ describe('campaign integration fixtures', () => { await fixtures.cleanup() } }) + + it('purges residue dobradinhas and their orphaned fichas on a municipality claim', async () => { + // OPS45: a crashed run leaves deputies linked through the claimed + // municipality's `stateDeputies`. The purge must delete the deputies and + // the fichas only a residue deputy referenced, clear the field, and + // preserve fichas that a live join still references. + const fixtures = createCampaignFixtures(payload) + try { + const municipality = await fixtures.getMunicipality() + const orphanDeputy = await fixtures.createStateDeputy() + const preservedContact = await fixtures.createContact() + const otherMunicipality = await fixtures.getMunicipality() + await fixtures.createLeadership({ + contact: preservedContact, + municipalities: [otherMunicipality.id], + }) + const preservedDeputy = await fixtures.createStateDeputy({ contact: preservedContact }) + await payload.update({ + collection: 'municipality', + id: municipality.id, + data: { stateDeputies: [orphanDeputy.id, preservedDeputy.id] }, + depth: 0, + overrideAccess: true, + }) + + await purgeMunicipalityResidue(payload, municipality.id) + + const deputyExists = async (id: number): Promise => { + const result = await payload.find({ + collection: 'stateDeputy', + where: { id: { equals: id } }, + depth: 0, + limit: 1, + overrideAccess: true, + }) + return result.docs.length === 1 + } + expect(await deputyExists(orphanDeputy.id)).toBe(false) + expect(await deputyExists(preservedDeputy.id)).toBe(false) + expect(await exists('contact', relationId(orphanDeputy.contact))).toBe(false) + expect(await exists('contact', preservedContact.id)).toBe(true) + const persistedMunicipality = await payload.findByID({ + collection: 'municipality', + id: municipality.id, + depth: 0, + overrideAccess: true, + }) + expect(relationIds(persistedMunicipality.stateDeputies ?? [])).toEqual([]) + } finally { + await fixtures.cleanup() + } + }) + + it('purges a shared ficha only after every joiner is gone (supporter + dobradinha)', async () => { + // OPS45 — the residue shape that crashed the purge: ONE ficha shared by a + // dobradinha (linked to municipality A) and a supporter (on municipality + // B), no account in between. The supporter purge must not delete the + // ficha while the deputy still references it (`stateDeputy.contact` is + // ON DELETE RESTRICT — deleting would throw), and the later claim of A + // must finish the job. + const fixtures = createCampaignFixtures(payload) + try { + const deputyMunicipality = await fixtures.getMunicipality() + const sharedContact = await fixtures.createContact() + const deputy = await fixtures.createStateDeputy({ contact: sharedContact }) + await payload.update({ + collection: 'municipality', + id: deputyMunicipality.id, + data: { stateDeputies: [deputy.id] }, + depth: 0, + overrideAccess: true, + }) + + const supporterMunicipality = await fixtures.getMunicipality() + const supporter = await fixtures.createSupporter({ + contact: sharedContact, + municipality: supporterMunicipality, + }) + + await purgeMunicipalityResidue(payload, supporterMunicipality.id) + expect(await exists('supporter', supporter.id)).toBe(false) + expect(await exists('contact', sharedContact.id)).toBe(true) + + await purgeMunicipalityResidue(payload, deputyMunicipality.id) + expect(await exists('contact', sharedContact.id)).toBe(false) + } finally { + await fixtures.cleanup() + } + }) }) diff --git a/tests/int/peopleList.int.spec.ts b/tests/int/peopleList.int.spec.ts index 91fbae47..a1bc7a71 100644 --- a/tests/int/peopleList.int.spec.ts +++ b/tests/int/peopleList.int.spec.ts @@ -4,8 +4,14 @@ import type { Payload } from 'payload' import { getPayload } from 'payload' import { beforeAll, describe, expect, it } from 'vitest' +import type { CampaignUser } from '@/payload-types' import config from '@/payload.config' -import { loadPeopleListPageData } from '@/utilities/people/peopleData' +import { + loadPeopleListPageData, + type PeopleListFilterFacets, + type PeopleRowViewModel, +} from '@/utilities/people/peopleData' +import { peoplePageSize, type PeopleListState } from '@/utilities/people/peopleListUrl' import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures' @@ -17,6 +23,38 @@ const campaignFixtures = installCampaignFixtures({ }, }) +/** + * OPS45 — the people list is GLOBAL and paginated (25 rows/page, sorted by + * name): residue from crashed runs and rows of parallel spec files legitimately + * occupy the first pages, so a presence assertion over page 1 alone is + * state-sensitive. Collect every page until one comes back short — a short + * page means the sweep is complete, and the bound self-heals when parallel + * files grow the list between fetches. Rows are deduped by contactID (a row + * that crosses a page boundary mid-sweep would otherwise appear twice, which + * would break count assertions). Facets come from page 1 (they are computed + * over the whole scoped set before pagination, so they are already global). + */ +const loadAllPeopleRows = async ( + listPayload: Payload, + user: CampaignUser, + state: PeopleListState, +): Promise<{ rows: PeopleRowViewModel[]; filterFacets: PeopleListFilterFacets }> => { + const byContact = new Map() + let filterFacets: PeopleListFilterFacets | undefined + for (let page = 1; ; page += 1) { + const result = await loadPeopleListPageData(listPayload, user, { ...state, page }) + filterFacets ??= result.filterFacets + for (const row of result.rows) { + if (!byContact.has(row.contactID)) byContact.set(row.contactID, row) + } + if (result.rows.length < peoplePageSize) break + } + return { + rows: [...byContact.values()], + filterFacets: filterFacets ?? { municipalityIDs: [], statuses: [], parties: [] }, + } +} + describe('C100 — lista unificada de pessoas (merge por Contact)', () => { beforeAll(async () => { payload = await getPayload({ config: await config }) @@ -27,47 +65,49 @@ describe('C100 — lista unificada de pessoas (merge por Contact)', () => { const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.getMunicipality() - const leadershipContact = await fixtures.createContact({ name: 'Maria de Jesus' }) + const mariaName = fixtures.personName('Maria de Jesus') + const leadershipContact = await fixtures.createContact({ name: mariaName }) const leadership = await fixtures.createLeadership({ contact: leadershipContact, municipalities: [municipality], }) - const deputyContact = await fixtures.createContact({ name: 'Ana Lima' }) + const anaName = fixtures.personName('Ana Lima') + const deputyContact = await fixtures.createContact({ name: anaName }) const deputy = await fixtures.createStateDeputy({ contact: deputyContact, party: 'PCdoB' }) const advisor = await fixtures.createCampaignUser('advisor') - const pureContact = await fixtures.createContact({ name: 'Só Ficha' }) + const pureContact = await fixtures.createContact({ name: fixtures.personName('Só Ficha') }) - const result = await loadPeopleListPageData(payload, coordinator, { page: 1 }) + const { rows, filterFacets } = await loadAllPeopleRows(payload, coordinator, { page: 1 }) - const maria = result.rows.find((row) => row.contactID === relationId(leadershipContact)) + const maria = rows.find((row) => row.contactID === relationId(leadershipContact)) expect(maria).toMatchObject({ - name: 'Maria de Jesus', + name: mariaName, leadershipID: leadership.id, supportStatus: 'engajado', }) expect(maria?.leadershipMunicipalityIDs).toContain(municipality.id) - const ana = result.rows.find((row) => row.contactID === relationId(deputyContact)) - expect(ana).toMatchObject({ name: 'Ana Lima', deputyID: deputy.id, party: 'PCdoB' }) + const ana = rows.find((row) => row.contactID === relationId(deputyContact)) + expect(ana).toMatchObject({ name: anaName, deputyID: deputy.id, party: 'PCdoB' }) // Staff with a ficha enters even with an EMPTY carteira (gate 2026-08-09). - const advisorRow = result.rows.find((row) => row.contactID === relationId(advisor.contact)) + const advisorRow = rows.find((row) => row.contactID === relationId(advisor.contact)) expect(advisorRow?.staff.map((account) => account.id)).toContain(advisor.id) expect(advisorRow?.assessoraMunicipalityIDs).toEqual([]) // A bare Contact with no role never enters. - expect(result.rows.some((row) => row.contactID === relationId(pureContact))).toBe(false) - expect(result.filterFacets.municipalityIDs).toContain(municipality.id) - expect(result.filterFacets.statuses).toContain('engajado') + expect(rows.some((row) => row.contactID === relationId(pureContact))).toBe(false) + expect(filterFacets.municipalityIDs).toContain(municipality.id) + expect(filterFacets.statuses).toContain('engajado') }) it('merges an account sharing the ficha (phone reuse) into the leadership row', async () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.getMunicipality() - const contact = await fixtures.createContact({ name: 'Dupla Função' }) + const contact = await fixtures.createContact({ name: fixtures.personName('Dupla Função') }) const leadership = await fixtures.createLeadership({ contact, municipalities: [municipality], @@ -75,12 +115,12 @@ describe('C100 — lista unificada de pessoas (merge por Contact)', () => { const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone }) expect(relationId(account.contact)).toBe(contact.id) - const result = await loadPeopleListPageData(payload, coordinator, { page: 1 }) - const rows = result.rows.filter((row) => row.contactID === contact.id) + const { rows } = await loadAllPeopleRows(payload, coordinator, { page: 1 }) + const mergedRows = rows.filter((row) => row.contactID === contact.id) - expect(rows).toHaveLength(1) - expect(rows[0]?.leadershipID).toBe(leadership.id) - expect(rows[0]?.staff.some((staff) => staff.id === account.id)).toBe(true) + expect(mergedRows).toHaveLength(1) + expect(mergedRows[0]?.leadershipID).toBe(leadership.id) + expect(mergedRows[0]?.staff.some((staff) => staff.id === account.id)).toBe(true) }) it('scopes every source to the advisor carteira', async () => { @@ -90,12 +130,18 @@ describe('C100 — lista unificada de pessoas (merge por Contact)', () => { const stranger = await fixtures.getMunicipality() await fixtures.assignMunicipalityAdvisors(administered, [advisor]) - const ownContact = await fixtures.createContact({ name: 'Própria Liderança' }) + const ownContact = await fixtures.createContact({ + name: fixtures.personName('Própria Liderança'), + }) await fixtures.createLeadership({ contact: ownContact, municipalities: [administered] }) - const alienContact = await fixtures.createContact({ name: 'Liderança Alheia' }) + const alienContact = await fixtures.createContact({ + name: fixtures.personName('Liderança Alheia'), + }) await fixtures.createLeadership({ contact: alienContact, municipalities: [stranger] }) - const deputyContact = await fixtures.createContact({ name: 'Aliada Local' }) + const deputyContact = await fixtures.createContact({ + name: fixtures.personName('Aliada Local'), + }) const deputy = await fixtures.createStateDeputy({ contact: deputyContact, party: 'PT' }) fixtures.touchMunicipality(administered) await payload.update({ @@ -109,8 +155,8 @@ describe('C100 — lista unificada de pessoas (merge por Contact)', () => { const unassigned = await fixtures.createCampaignUser('advisor') await fixtures.assignMunicipalityAdvisors(administered, [advisor, colleague]) - const result = await loadPeopleListPageData(payload, advisor, { page: 1 }) - const contactIDs = result.rows.map((row) => row.contactID) + const { rows } = await loadAllPeopleRows(payload, advisor, { page: 1 }) + const contactIDs = rows.map((row) => row.contactID) expect(contactIDs).toContain(relationId(ownContact)) expect(contactIDs).not.toContain(relationId(alienContact)) @@ -126,31 +172,36 @@ describe('C100 — lista unificada de pessoas (merge por Contact)', () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.getMunicipality() - const maria = await fixtures.createContact({ name: 'Maria de Jesus' }) + const mariaName = fixtures.personName('Maria de Jesus') + const maria = await fixtures.createContact({ name: mariaName }) await fixtures.createLeadership({ contact: maria, municipalities: [municipality] }) - const joao = await fixtures.createContact({ name: 'João do Brejo' }) + const joao = await fixtures.createContact({ name: fixtures.personName('João do Brejo') }) await fixtures.createLeadership({ contact: joao, municipalities: [municipality] }) - const result = await loadPeopleListPageData(payload, coordinator, { page: 1, q: 'Maria' }) - const names = result.rows.map((row) => row.name) + const { rows } = await loadAllPeopleRows(payload, coordinator, { page: 1, q: 'Maria' }) + const names = rows.map((row) => row.name) - expect(names).toContain('Maria de Jesus') - expect(names).not.toContain('João do Brejo') + expect(names).toContain(mariaName) + expect(names).not.toContain(joao.name) }) it('filters by party and exposes the party facet from the recorte (C130)', async () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.getMunicipality() - const deputyContact = await fixtures.createContact({ name: 'Deputada do Partido' }) + const deputyContact = await fixtures.createContact({ + name: fixtures.personName('Deputada do Partido'), + }) await fixtures.createStateDeputy({ contact: deputyContact, party: 'PSOL' }) - const noDeputyContact = await fixtures.createContact({ name: 'Sem Dobradinha' }) + const noDeputyContact = await fixtures.createContact({ + name: fixtures.personName('Sem Dobradinha'), + }) await fixtures.createLeadership({ contact: noDeputyContact, municipalities: [municipality] }) - const all = await loadPeopleListPageData(payload, coordinator, { page: 1 }) + const all = await loadAllPeopleRows(payload, coordinator, { page: 1 }) expect(all.filterFacets.parties).toContain('PSOL') - const filtered = await loadPeopleListPageData(payload, coordinator, { + const filtered = await loadAllPeopleRows(payload, coordinator, { page: 1, parties: ['PSOL'], }) @@ -162,7 +213,7 @@ describe('C100 — lista unificada de pessoas (merge por Contact)', () => { const fixtures = campaignFixtures() const leader = await fixtures.createCampaignUser('leader') const municipality = await fixtures.getMunicipality() - const contact = await fixtures.createContact({ name: 'Alguém' }) + const contact = await fixtures.createContact({ name: fixtures.personName('Alguém') }) await fixtures.createLeadership({ contact, municipalities: [municipality] }) const result = await loadPeopleListPageData(payload, leader, { page: 1 }) diff --git a/tests/int/personDelete.int.spec.ts b/tests/int/personDelete.int.spec.ts index 4ea6b495..ba647e51 100644 --- a/tests/int/personDelete.int.spec.ts +++ b/tests/int/personDelete.int.spec.ts @@ -52,7 +52,11 @@ describe('C100 — apagar pessoa (manifest + cascata)', () => { const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.getMunicipality() - const contact = await fixtures.createContact({ name: 'Maria de Jesus' }) + // OPS45 — the dobradinha name is a GLOBAL invariant + // (`assertStateDeputyNameAvailable`): a fixed name would collide with any + // residue row a crashed run left behind, so it must be unique per run. + const contactName = fixtures.personName('Maria de Jesus') + const contact = await fixtures.createContact({ name: contactName }) const leadership = await fixtures.createLeadership({ contact, municipalities: [municipality] }) await fixtures.createVotePledge({ leadership, municipality }) const advisor = await fixtures.createCampaignUser('advisor', { phone: contact.phone }) @@ -63,7 +67,7 @@ describe('C100 — apagar pessoa (manifest + cascata)', () => { const manifest = await loadPersonDeleteManifest(payload, contact.id) expect(manifest).not.toBeNull() - expect(manifest?.contact.name).toBe('Maria de Jesus') + expect(manifest?.contact.name).toBe(contactName) expect(manifest?.leaderships).toHaveLength(1) expect(manifest?.leaderships[0]?.municipalityNames).toContain(municipality.name) expect(manifest?.stateDeputies).toHaveLength(1) @@ -80,7 +84,7 @@ describe('C100 — apagar pessoa (manifest + cascata)', () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.getMunicipality() - const contact = await fixtures.createContact({ name: 'João do Brejo' }) + const contact = await fixtures.createContact({ name: fixtures.personName('João do Brejo') }) const leadership = await fixtures.createLeadership({ contact, municipalities: [municipality] }) await fixtures.createVotePledge({ leadership, municipality }) await fixtures.createStateDeputy({ contact, party: 'PCdoB' }) @@ -106,7 +110,7 @@ describe('C100 — apagar pessoa (manifest + cascata)', () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.getMunicipality() - const contact = await fixtures.createContact({ name: 'Ana Lima' }) + const contact = await fixtures.createContact({ name: fixtures.personName('Ana Lima') }) await fixtures.createLeadership({ contact, municipalities: [municipality] }) const consent = await fixtures.createConsent() const subscription = await payload.create({ @@ -148,7 +152,7 @@ describe('C100 — apagar pessoa (manifest + cascata)', () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.getMunicipality() - const contact = await fixtures.createContact({ name: 'Protegida' }) + const contact = await fixtures.createContact({ name: fixtures.personName('Protegida') }) await fixtures.createLeadership({ contact, municipalities: [municipality] }) await fixtures.createCampaignUser('coordinator', { phone: contact.phone }) @@ -165,7 +169,7 @@ describe('C100 — apagar pessoa (manifest + cascata)', () => { const fixtures = campaignFixtures() const advisor = await fixtures.createCampaignUser('advisor') const municipality = await fixtures.getMunicipality() - const contact = await fixtures.createContact({ name: 'Alguém' }) + const contact = await fixtures.createContact({ name: fixtures.personName('Alguém') }) await fixtures.createLeadership({ contact, municipalities: [municipality] }) await expect(deletePersonRecord(payload, advisor, contact.id)).rejects.toThrow( From 312c0048bd1a31eb31be2e0666e8ed8ac0ab46de Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 13 Aug 2026 14:40:18 -0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(OPS45):=20extrair=20purge=20de=20res?= =?UTF-8?q?=C3=ADduo=20para=20m=C3=B3dulo=20vitest-free=20(e2e=20quebrava?= =?UTF-8?q?=20no=20CI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O import do campaignFixtures.ts (que importa vitest) no fixture e2e crashava a coleção no processo do Playwright: TypeError: Cannot redefine property: Symbol($jest-matchers-object) em @vitest/expect — falha nos 2 shards e2e do CI. purgeMunicipalityResidue/relationId/relationIds/RelationshipValue agora vivem em tests/helpers/campaignResidue.ts (sem vitest), re-exportados por campaignFixtures para os importadores existentes. --- tests/e2e/fixtures/campaignE2EFixtures.ts | 2 +- tests/helpers/campaignFixtures.ts | 260 +-------------------- tests/helpers/campaignResidue.ts | 266 ++++++++++++++++++++++ 3 files changed, 273 insertions(+), 255 deletions(-) create mode 100644 tests/helpers/campaignResidue.ts diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index 3f079712..c1472ed5 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -9,7 +9,7 @@ import type { CampaignUser } from '../../../src/payload-types.js' import config from '../../../src/payload.config.js' import { withPayloadTransaction } from '../../../src/utilities/payloadTransaction.js' import { assertTestDatabase } from '../../helpers/assertTestDatabase.js' -import { purgeMunicipalityResidue } from '../../helpers/campaignFixtures.js' +import { purgeMunicipalityResidue } from '../../helpers/campaignResidue.js' import { withInviteConsent } from '../../helpers/testDatabaseLease.js' import { test as base, expect } from './e2eTest.js' diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 44701969..5538bff9 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -21,6 +21,7 @@ import type { VotePledge, } from '@/payload-types' import { withPayloadTransaction } from '@/utilities/payloadTransaction' +import { purgeMunicipalityResidue, relationId, relationIds } from './campaignResidue' /** * Campaign fixtures for the Município model. Municipalities are SEEDED reference rows @@ -169,25 +170,11 @@ const defaultConsentText = (text: string): Consent['text'] => ({ }, }) -type RelationshipValue = number | { id: number } - -/** - * Id of a relationship field at any depth. - * - * Specs use this instead of `relationshipId` from `src/utilities/relationship` - * so an assertion never runs through the production helper it is checking, and - * instead of `uniqueRelationshipIds`, which dedups — hiding a duplicated link. - */ -export function relationId(value: RelationshipValue): number -export function relationId(value: RelationshipValue | null | undefined): number | undefined -export function relationId(value: RelationshipValue | null | undefined): number | undefined { - if (value === null || value === undefined) return undefined - return typeof value === 'number' ? value : value.id -} - -/** Order-preserving, non-deduping ids of a `hasMany` relationship field. */ -export const relationIds = (values: readonly RelationshipValue[] | null | undefined): number[] => - (values ?? []).map((value) => relationId(value)) +// `relationId`/`relationIds`/`purgeMunicipalityResidue` live in +// `campaignResidue.ts` (vitest-free — the e2e fixture imports it inside the +// Playwright process) and are re-exported here for the existing importers. +export { purgeMunicipalityResidue, relationId, relationIds } from './campaignResidue' +export type { RelationshipValue } from './campaignResidue' const bulkErrors = (result: unknown): unknown[] => { if ( @@ -233,241 +220,6 @@ const nextAllocatedMunicipalityIndex = async ( return value % catalogSize } -/** - * D10 F1 / OPS45: delete CONTACT rows an aborted run orphaned (no other join - * references them). Production `removeSupporterData` only anonymizes, so its - * reference rule is a subset of what a DELETE needs: every join that can hold - * a ficha must be checked — leadership/signature/subscription/supporter, the - * C99 account link (`campaignUser.contact`) and the `stateDeputy` FK - * (`ON DELETE RESTRICT`, so an unchecked deputy would make the delete throw). - * Reimplemented here so the fixture never runs production code under test. - */ -const deleteOrphanedResidueContacts = async ( - payload: Payload, - contactIDs: Array, -): Promise => { - const uniqueIDs = [...new Set(contactIDs.filter((id): id is number => id !== undefined))] - if (uniqueIDs.length === 0) return - const [leadershipRefs, signatureRefs, subscriptionRefs, supporterRefs, accountRefs, deputyRefs] = - await Promise.all([ - payload.find({ - collection: 'leadership', - where: { contact: { in: uniqueIDs } }, - depth: 0, - pagination: false, - }), - payload.find({ - collection: 'signature', - where: { contact: { in: uniqueIDs } }, - depth: 0, - pagination: false, - }), - payload.find({ - collection: 'subscription', - where: { contact: { in: uniqueIDs } }, - depth: 0, - pagination: false, - }), - payload.find({ - collection: 'supporter', - where: { contact: { in: uniqueIDs } }, - depth: 0, - pagination: false, - }), - payload.find({ - collection: 'campaignUser', - where: { contact: { in: uniqueIDs } }, - depth: 0, - pagination: false, - }), - payload.find({ - collection: 'stateDeputy', - where: { contact: { in: uniqueIDs } }, - depth: 0, - pagination: false, - }), - ]) - const referenced = new Set() - for (const doc of [ - ...leadershipRefs.docs, - ...signatureRefs.docs, - ...subscriptionRefs.docs, - ...supporterRefs.docs, - ...accountRefs.docs, - ...deputyRefs.docs, - ]) { - const contactID = relationId(doc.contact) - if (contactID !== undefined) referenced.add(contactID) - } - const orphanContactIDs = uniqueIDs.filter((id) => !referenced.has(id)) - if (orphanContactIDs.length > 0) { - await payload.delete({ - collection: 'contact', - where: { id: { in: orphanContactIDs } }, - depth: 0, - }) - } -} - -/** - * 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) and - * the dobradinha residue (OPS45): deputies linked through - * `municipality.stateDeputies`, their orphaned fichas, and the field itself. - */ -export const purgeMunicipalityResidue = async ( - payload: Payload, - municipalityID: number, -): Promise => { - const [pledges, updates, demands, leaderships, supporters, activities, feeds] = await Promise.all( - [ - payload.find({ - collection: 'votePledge', - where: { municipality: { equals: municipalityID } }, - depth: 0, - pagination: false, - select: {}, - }), - payload.find({ - collection: 'municipalityUpdate', - where: { municipality: { equals: municipalityID } }, - depth: 0, - pagination: false, - select: {}, - }), - payload.find({ - collection: 'campaignDemand', - where: { municipality: { equals: municipalityID } }, - depth: 0, - pagination: false, - select: {}, - }), - payload.find({ - collection: 'leadership', - where: { municipalities: { in: [municipalityID] } }, - depth: 0, - pagination: false, - select: {}, - }), - payload.find({ - collection: 'supporter', - where: { municipality: { equals: municipalityID } }, - depth: 0, - pagination: false, - }), - payload.find({ - collection: 'activity', - where: { municipality: { equals: municipalityID } }, - depth: 0, - pagination: false, - select: {}, - }), - payload.find({ - collection: 'calendarFeed', - where: { filterMunicipality: { equals: municipalityID } }, - depth: 0, - pagination: false, - select: {}, - }), - ], - ) - - // Residue invites reference residue leaderships (FK), so they must be - // found and deleted before the leaderships — otherwise the leadership - // delete aborts and the residue poisons every later claim of this municipality. - const leadershipIDs = leaderships.docs.map((doc) => doc.id) - const invites = - leadershipIDs.length > 0 - ? await payload.find({ - collection: 'campaignInvite', - where: { leadership: { in: leadershipIDs } }, - depth: 0, - pagination: false, - select: {}, - }) - : { docs: [] } - - const deletions: Array<{ - collection: - | 'campaignInvite' - | 'votePledge' - | 'municipalityUpdate' - | 'campaignDemand' - | 'leadership' - | 'supporter' - | 'activity' - | 'calendarFeed' - ids: number[] - }> = [ - { collection: 'campaignInvite', ids: invites.docs.map((doc) => doc.id) }, - { collection: 'votePledge', ids: pledges.docs.map((doc) => doc.id) }, - { collection: 'municipalityUpdate', ids: updates.docs.map((doc) => doc.id) }, - { collection: 'campaignDemand', ids: demands.docs.map((doc) => doc.id) }, - { collection: 'activity', ids: activities.docs.map((doc) => doc.id) }, - { collection: 'calendarFeed', ids: feeds.docs.map((doc) => doc.id) }, - { collection: 'leadership', ids: leadershipIDs }, - { collection: 'supporter', ids: supporters.docs.map((doc) => doc.id) }, - ] - for (const { collection, ids } of deletions) { - if (ids.length === 0) continue - await payload.delete({ - collection, - where: { id: { in: ids } }, - 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. - await deleteOrphanedResidueContacts( - payload, - supporters.docs.map((doc) => relationId(doc.contact)), - ) - - // OPS45 — dobradinha residue: deputies linked through the freshly claimed - // municipality's `stateDeputies` belong to the crashed run that claimed it - // (the allocator contract rules out a live owner). Delete the deputies, - // the fichas they orphaned, and clear the field so a later claim starts - // clean. The deputy contacts are captured BEFORE the delete. - const claimed = await payload.findByID({ - collection: 'municipality', - id: municipalityID, - depth: 0, - select: { stateDeputies: true }, - overrideAccess: true, - }) - const residueDeputyIDs = relationIds(claimed?.stateDeputies ?? []) - if (residueDeputyIDs.length > 0) { - const deputies = await payload.find({ - collection: 'stateDeputy', - where: { id: { in: residueDeputyIDs } }, - depth: 0, - pagination: false, - select: { contact: true }, - overrideAccess: true, - }) - await payload.delete({ - collection: 'stateDeputy', - where: { id: { in: residueDeputyIDs } }, - depth: 0, - }) - await deleteOrphanedResidueContacts( - payload, - deputies.docs.map((doc) => relationId(doc.contact)), - ) - await payload.update({ - collection: 'municipality', - id: municipalityID, - data: { stateDeputies: [] }, - depth: 0, - overrideAccess: true, - }) - } -} - export class CampaignFixtures { readonly payload: Payload readonly runID: string diff --git a/tests/helpers/campaignResidue.ts b/tests/helpers/campaignResidue.ts new file mode 100644 index 00000000..1ee080b2 --- /dev/null +++ b/tests/helpers/campaignResidue.ts @@ -0,0 +1,266 @@ +import type { Payload } from 'payload' + +/** + * Municipality residue purge for the test fixtures — shared by the int + * (`campaignFixtures.ts`) and e2e (`campaignE2EFixtures.ts`) suites. + * + * Deliberately VITEST-FREE: the e2e fixture imports this module inside the + * Playwright process, and a vitest import there crashes at load time + * (`TypeError: Cannot redefine property: Symbol($$jest-matchers-object)` from + * `@vitest/expect` — OPS45/CI 2026-08-13). + */ + +export type RelationshipValue = number | { id: number } + +/** + * Id of a relationship field at any depth. + * + * Specs use this instead of `relationshipId` from `src/utilities/relationship` + * so an assertion never runs through the production helper it is checking, and + * instead of `uniqueRelationshipIds`, which dedups — hiding a duplicated link. + */ +export function relationId(value: RelationshipValue): number +export function relationId(value: RelationshipValue | null | undefined): number | undefined +export function relationId(value: RelationshipValue | null | undefined): number | undefined { + if (value === null || value === undefined) return undefined + return typeof value === 'number' ? value : value.id +} + +/** Order-preserving, non-deduping ids of a `hasMany` relationship field. */ +export const relationIds = (values: readonly RelationshipValue[] | null | undefined): number[] => + (values ?? []).map((value) => relationId(value)) + +/** + * D10 F1 / OPS45: delete CONTACT rows an aborted run orphaned (no other join + * references them). Production `removeSupporterData` only anonymizes, so its + * reference rule is a subset of what a DELETE needs: every join that can hold + * a ficha must be checked — leadership/signature/subscription/supporter, the + * C99 account link (`campaignUser.contact`) and the `stateDeputy` FK + * (`ON DELETE RESTRICT`, so an unchecked deputy would make the delete throw). + * Reimplemented here so the fixture never runs production code under test. + */ +const deleteOrphanedResidueContacts = async ( + payload: Payload, + contactIDs: Array, +): Promise => { + const uniqueIDs = [...new Set(contactIDs.filter((id): id is number => id !== undefined))] + if (uniqueIDs.length === 0) return + const [leadershipRefs, signatureRefs, subscriptionRefs, supporterRefs, accountRefs, deputyRefs] = + await Promise.all([ + payload.find({ + collection: 'leadership', + where: { contact: { in: uniqueIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'signature', + where: { contact: { in: uniqueIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'subscription', + where: { contact: { in: uniqueIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'supporter', + where: { contact: { in: uniqueIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'campaignUser', + where: { contact: { in: uniqueIDs } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'stateDeputy', + where: { contact: { in: uniqueIDs } }, + depth: 0, + pagination: false, + }), + ]) + const referenced = new Set() + for (const doc of [ + ...leadershipRefs.docs, + ...signatureRefs.docs, + ...subscriptionRefs.docs, + ...supporterRefs.docs, + ...accountRefs.docs, + ...deputyRefs.docs, + ]) { + const contactID = relationId(doc.contact) + if (contactID !== undefined) referenced.add(contactID) + } + const orphanContactIDs = uniqueIDs.filter((id) => !referenced.has(id)) + if (orphanContactIDs.length > 0) { + await payload.delete({ + collection: 'contact', + where: { id: { in: orphanContactIDs } }, + depth: 0, + }) + } +} + +/** + * 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) and + * the dobradinha residue (OPS45): deputies linked through + * `municipality.stateDeputies`, their orphaned fichas, and the field itself. + */ +export const purgeMunicipalityResidue = async ( + payload: Payload, + municipalityID: number, +): Promise => { + const [pledges, updates, demands, leaderships, supporters, activities, feeds] = await Promise.all( + [ + payload.find({ + collection: 'votePledge', + where: { municipality: { equals: municipalityID } }, + depth: 0, + pagination: false, + select: {}, + }), + payload.find({ + collection: 'municipalityUpdate', + where: { municipality: { equals: municipalityID } }, + depth: 0, + pagination: false, + select: {}, + }), + payload.find({ + collection: 'campaignDemand', + where: { municipality: { equals: municipalityID } }, + depth: 0, + pagination: false, + select: {}, + }), + payload.find({ + collection: 'leadership', + where: { municipalities: { in: [municipalityID] } }, + depth: 0, + pagination: false, + select: {}, + }), + payload.find({ + collection: 'supporter', + where: { municipality: { equals: municipalityID } }, + depth: 0, + pagination: false, + }), + payload.find({ + collection: 'activity', + where: { municipality: { equals: municipalityID } }, + depth: 0, + pagination: false, + select: {}, + }), + payload.find({ + collection: 'calendarFeed', + where: { filterMunicipality: { equals: municipalityID } }, + depth: 0, + pagination: false, + select: {}, + }), + ], + ) + + // Residue invites reference residue leaderships (FK), so they must be + // found and deleted before the leaderships — otherwise the leadership + // delete aborts and the residue poisons every later claim of this municipality. + const leadershipIDs = leaderships.docs.map((doc) => doc.id) + const invites = + leadershipIDs.length > 0 + ? await payload.find({ + collection: 'campaignInvite', + where: { leadership: { in: leadershipIDs } }, + depth: 0, + pagination: false, + select: {}, + }) + : { docs: [] } + + const deletions: Array<{ + collection: + | 'campaignInvite' + | 'votePledge' + | 'municipalityUpdate' + | 'campaignDemand' + | 'leadership' + | 'supporter' + | 'activity' + | 'calendarFeed' + ids: number[] + }> = [ + { collection: 'campaignInvite', ids: invites.docs.map((doc) => doc.id) }, + { collection: 'votePledge', ids: pledges.docs.map((doc) => doc.id) }, + { collection: 'municipalityUpdate', ids: updates.docs.map((doc) => doc.id) }, + { collection: 'campaignDemand', ids: demands.docs.map((doc) => doc.id) }, + { collection: 'activity', ids: activities.docs.map((doc) => doc.id) }, + { collection: 'calendarFeed', ids: feeds.docs.map((doc) => doc.id) }, + { collection: 'leadership', ids: leadershipIDs }, + { collection: 'supporter', ids: supporters.docs.map((doc) => doc.id) }, + ] + for (const { collection, ids } of deletions) { + if (ids.length === 0) continue + await payload.delete({ + collection, + where: { id: { in: ids } }, + 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. + await deleteOrphanedResidueContacts( + payload, + supporters.docs.map((doc) => relationId(doc.contact)), + ) + + // OPS45 — dobradinha residue: deputies linked through the freshly claimed + // municipality's `stateDeputies` belong to the crashed run that claimed it + // (the allocator contract rules out a live owner). Delete the deputies, + // the fichas they orphaned, and clear the field so a later claim starts + // clean. The deputy contacts are captured BEFORE the delete. + const claimed = await payload.findByID({ + collection: 'municipality', + id: municipalityID, + depth: 0, + select: { stateDeputies: true }, + overrideAccess: true, + }) + const residueDeputyIDs = relationIds(claimed?.stateDeputies ?? []) + if (residueDeputyIDs.length > 0) { + const deputies = await payload.find({ + collection: 'stateDeputy', + where: { id: { in: residueDeputyIDs } }, + depth: 0, + pagination: false, + select: { contact: true }, + overrideAccess: true, + }) + await payload.delete({ + collection: 'stateDeputy', + where: { id: { in: residueDeputyIDs } }, + depth: 0, + }) + await deleteOrphanedResidueContacts( + payload, + deputies.docs.map((doc) => relationId(doc.contact)), + ) + await payload.update({ + collection: 'municipality', + id: municipalityID, + data: { stateDeputies: [] }, + depth: 0, + overrideAccess: true, + }) + } +} From b5d456a37b4fa583c86f725f27036d1a4478f24e Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 13 Aug 2026 14:44:48 -0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(OPS45):=20tirar=20export=20desnecess?= =?UTF-8?q?=C3=A1rio=20de=20RelationshipValue=20(knip=20CI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/helpers/campaignFixtures.ts | 1 - tests/helpers/campaignResidue.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 5538bff9..ca03523a 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -174,7 +174,6 @@ const defaultConsentText = (text: string): Consent['text'] => ({ // `campaignResidue.ts` (vitest-free — the e2e fixture imports it inside the // Playwright process) and are re-exported here for the existing importers. export { purgeMunicipalityResidue, relationId, relationIds } from './campaignResidue' -export type { RelationshipValue } from './campaignResidue' const bulkErrors = (result: unknown): unknown[] => { if ( diff --git a/tests/helpers/campaignResidue.ts b/tests/helpers/campaignResidue.ts index 1ee080b2..73450eb6 100644 --- a/tests/helpers/campaignResidue.ts +++ b/tests/helpers/campaignResidue.ts @@ -10,7 +10,7 @@ import type { Payload } from 'payload' * `@vitest/expect` — OPS45/CI 2026-08-13). */ -export type RelationshipValue = number | { id: number } +type RelationshipValue = number | { id: number } /** * Id of a relationship field at any depth.