Merge pull request #734 from fsolla/OPS45-e2e-campaigne2efixtures-deixa-lixo-no-banco-de-teste-c
OPS45: e2e fixtures — purge de resíduo no claim + robustez dos int specs
This commit is contained in:
commit
b420598328
8 changed files with 685 additions and 226 deletions
146
docs/plans/ops45-e2e-fixtures-residuo-banco-teste-impl.md
Normal file
146
docs/plans/ops45-e2e-fixtures-residuo-banco-teste-impl.md
Normal 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
|
||||
75
docs/plans/ops46-infra-teste-e2e-local-allocator.md
Normal file
75
docs/plans/ops46-infra-teste-e2e-local-allocator.md
Normal 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
|
||||
|
|
@ -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/campaignResidue.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
|
||||
|
|
|
|||
|
|
@ -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,10 @@ 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'
|
||||
|
||||
const bulkErrors = (result: unknown): unknown[] => {
|
||||
if (
|
||||
|
|
@ -233,172 +219,6 @@ const nextAllocatedMunicipalityIndex = async (
|
|||
return value % catalogSize
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export const purgeMunicipalityResidue = async (
|
||||
payload: Payload,
|
||||
municipalityID: number,
|
||||
): Promise<void> => {
|
||||
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 — 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CampaignFixtures {
|
||||
readonly payload: Payload
|
||||
readonly runID: string
|
||||
|
|
|
|||
266
tests/helpers/campaignResidue.ts
Normal file
266
tests/helpers/campaignResidue.ts
Normal file
|
|
@ -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).
|
||||
*/
|
||||
|
||||
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<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) 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<void> => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue