chore(OPS45): e2e fixtures — purge de resíduo no claim de município + robustez dos int specs (Closes #723)

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.
This commit is contained in:
Francisco Solla 2026-08-13 14:18:54 -03:00
parent 85e5aa81e1
commit bf25100ed5
7 changed files with 537 additions and 95 deletions

View file

@ -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-<uuid>', 'Coordenadora C128-<uuid>',
'deputado-<uuid>') 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-<runID>' (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<br/>+ 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<br/>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

View file

@ -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 '<repo>/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-<uuid>') — 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

View file

@ -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

View file

@ -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<number | undefined>,
): Promise<void> => {
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<number>()
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<number>()
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,
})
}
}

View file

@ -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<boolean> => {
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()
}
})
})

View file

@ -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<number, PeopleRowViewModel>()
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 })

View file

@ -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(