teqo-backup/tests/int/campaignSupporter.int.spec.ts
Francisco Solla adf0dbaba9 fix(test): S9+ — harness ciente do guard de Consent (CI do #771)
- campaignSupporter: destaca voteIntentionConsent dos supporters antes de
  purgar o consent canônico de intenção de voto — o seed mínimo cria vínculos
  vivos nessa chave, e o delete passou a depender do SET NULL silencioso que
  o guard agora bloqueia (falha determinística em DB fresca de CI).
- campaignInvite: purga da chave lideranca-autopreenchimento na janela
  mutável passa a raw SQL (precedente Onda0/lease) — lifecycle de harness
  nunca deve passar pelo caminho admin/beforeDelete.
2026-08-25 00:13:47 -03:00

667 lines
23 KiB
TypeScript

// @vitest-environment node
import { getPayload, type Payload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import {
confirmSupporterImportRecord,
createSupporterRecord,
previewSupporterImportText,
removeSupporterDataRecord,
setSupporterVoteIntentionRecord,
} from '@/app/(campaign)/campanha/actions/supporter'
import {
SUPPORTER_IMPORT_BATCH_EMPTY_MESSAGE,
supporterCreateSchema,
} from '@/lib/schemas/supporter'
import config from '@/payload.config'
import {
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_VOTE_INTENTION_CONSENT_KEY,
} from '@/utilities/campaignConsent'
import {
isPreviewErrorRow,
SUPPORTER_IMPORT_SHARED_PHONE_MESSAGE,
} from '@/utilities/supporter/supporterImport'
import {
loadSupporterListOverviewData,
loadSupportersPageData,
} from '@/utilities/supporter/supporterPageData'
import { buildSupporterListWhere } from '@/utilities/supporter/supporterUi'
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
import {
ensureLeasedConsent,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY,
withMissingInviteConsentFixture,
} from '../helpers/testDatabaseLease'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
/**
* Shared consent rows (stable keys) are leased fixtures: ensured under an
* advisory-lock lease and never owned/deleted, so parallel spec files can rely
* on them (see `leasedConsentKeys` in campaignFixtures.ts).
*/
const ensureConsentByKey = (key: string, leaseKey: string) =>
ensureLeasedConsent(payload, { consentKey: key, leaseKey })
const ensureSupporterConsents = async () => {
const registration = await ensureConsentByKey(
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
)
const voteIntention = await ensureConsentByKey(
SUPPORTER_VOTE_INTENTION_CONSENT_KEY,
SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY,
)
return { registration, voteIntention }
}
describe('campaign supporter domain', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('requires phone and registration consent on create schema', () => {
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phones: ['71999990000'],
consentAccepted: true,
}).success,
).toBe(true)
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phones: ['71999990000'],
}).success,
).toBe(false)
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phones: ['invalid'],
consentAccepted: true,
}).success,
).toBe(false)
})
it('fails closed when supporter registration consent is missing', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
await withMissingInviteConsentFixture(
payload,
async () => {
await expect(
createSupporterRecord(payload, coordinator, {
name: fixtures.value('Apoiador'),
phones: [fixtures.phone()],
consentAccepted: true,
}),
).rejects.toThrow(/Consentimento de cadastro de apoiador ainda não configurado/)
},
{
consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
},
)
})
it('creates a supporter with contact upsert and blocks leadership coexistence', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const phone = fixtures.phone()
const created = await createSupporterRecord(payload, coordinator, {
name: fixtures.value('Apoiador'),
phones: [phone],
city: 'Salvador',
consentAccepted: true,
})
fixtures.own('supporter', created.id)
fixtures.own('contact', created.contact)
expect(created.contactReused).toBe(false)
expect(created.source).toBe('manual')
const contact = await fixtures.createContact()
await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
createdBy: coordinator.id,
})
await expect(
createSupporterRecord(payload, coordinator, {
name: contact.name,
phones: [contact.phone],
municipality: municipality.id,
consentAccepted: true,
}),
).rejects.toThrow(/já é liderança neste município/)
})
it('scopes advisor create to administered municipalities and blocks municipality-less create', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
const otherMunicipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
await expect(
createSupporterRecord(payload, advisor, {
name: fixtures.value('Apoiador'),
phones: [fixtures.phone()],
consentAccepted: true,
}),
).rejects.toThrow(/sem município/)
await expect(
createSupporterRecord(payload, advisor, {
name: fixtures.value('Apoiador'),
phones: [fixtures.phone()],
municipality: otherMunicipality.id,
consentAccepted: true,
}),
).rejects.toThrow()
const created = await createSupporterRecord(payload, advisor, {
name: fixtures.value('Apoiador'),
phones: [fixtures.phone()],
municipality: municipality.id,
consentAccepted: true,
})
fixtures.own('supporter', created.id)
fixtures.own('contact', created.contact)
expect(created.id).toBeGreaterThan(0)
})
it('requires highlighted vote-intention consent before setting intention', async () => {
const fixtures = campaignFixtures()
const registration = await ensureConsentByKey(
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
)
const coordinator = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const supporter = await fixtures.createSupporter({
contact: contact.id,
consent: registration.id,
consentContentHash: 'hash',
consentedAt: new Date().toISOString(),
createdBy: coordinator.id,
})
const existingVoteConsent = await payload.find({
collection: 'consent',
where: { key: { equals: SUPPORTER_VOTE_INTENTION_CONSENT_KEY } },
depth: 0,
limit: 1,
})
if (existingVoteConsent.docs[0]) {
// S9+: Consent.beforeDelete refuses deleting an in-use legal text. The
// minimal seed links seeded supporters to this canonical consent, so
// detach the live voteIntentionConsent references FIRST — the explicit,
// ordered version of the FK `ON DELETE SET NULL` this purge relied on
// silently before the guard existed.
await payload.update({
collection: 'supporter',
where: { voteIntentionConsent: { equals: existingVoteConsent.docs[0].id } },
data: { voteIntentionConsent: null },
overrideAccess: true,
})
await payload.delete({
collection: 'consent',
id: existingVoteConsent.docs[0].id,
})
}
await expect(
setSupporterVoteIntentionRecord(payload, coordinator, {
id: supporter.id,
voteIntention: 'certo',
voteIntentionConsentAccepted: true,
}),
).rejects.toThrow(/Consentimento de intenção de voto ainda não configurado/)
await ensureConsentByKey(
SUPPORTER_VOTE_INTENTION_CONSENT_KEY,
SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY,
)
const updated = await setSupporterVoteIntentionRecord(payload, coordinator, {
id: supporter.id,
voteIntention: 'certo',
voteIntentionConsentAccepted: true,
})
expect(updated.voteIntention).toBe('certo')
expect(updated.voteIntentionConsentedAt).toBeTruthy()
})
it('previews and confirms CSV import for the coordinator only', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const phoneOk = fixtures.phone()
const phoneDup = fixtures.phone()
const existing = await fixtures.createContact({ phones: [{ value: phoneDup }] })
await fixtures.createSupporter({
contact: existing.id,
createdBy: coordinator.id,
})
const csv = [
'nome,telefone,municipio,intencao',
`Ana Silva,${phoneOk},Salvador,certo`,
`Bruno Costa,${phoneDup},Salvador,indeciso`,
'Carla,119999,Salvador,certo',
'Diego Souza,71988887777,Cidade Inventada,certo',
].join('\n')
await expect(previewSupporterImportText(payload, advisor, csv)).rejects.toThrow(
/Coordenador Geral/,
)
const preview = await previewSupporterImportText(payload, coordinator, csv)
expect(preview.counts.ok).toBe(1)
expect(preview.counts.duplicate).toBe(1)
expect(preview.counts.error).toBeGreaterThanOrEqual(2)
expect(preview.importToken).toBeTruthy()
expect(preview.sampleRows.length).toBeGreaterThan(0)
const result = await confirmSupporterImportRecord(payload, coordinator, {
operatorAttested: true,
importToken: preview.importToken,
})
expect(result.created).toBe(1)
// Single-use token: a second confirm with the same token must fail.
await expect(
confirmSupporterImportRecord(payload, coordinator, {
operatorAttested: true,
importToken: preview.importToken,
}),
).rejects.toThrow(/não encontrado|expirado|inválido/)
const contact = await payload.find({
collection: 'contact',
where: { 'phones.value': { equals: phoneOk } },
depth: 0,
limit: 1,
overrideAccess: true,
})
expect(contact.totalDocs).toBe(1)
fixtures.own('contact', contact.docs[0]!.id)
const created = await payload.find({
collection: 'supporter',
where: { contact: { equals: contact.docs[0]!.id } },
depth: 0,
limit: 1,
overrideAccess: true,
})
expect(created.totalDocs).toBe(1)
fixtures.own('supporter', created.docs[0]!.id)
})
it('flags a CSV phone shared by two fichas for manual resolution (C111)', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents()
const coordinator = await fixtures.createCampaignUser('coordinator')
const sharedPhone = fixtures.phone()
await fixtures.createContact({ name: 'Comitê Central', phones: [{ value: sharedPhone }] })
await fixtures.createContact({ name: 'Ana', phones: [{ value: sharedPhone }] })
const csv = [
'nome,telefone,municipio,intencao',
`Maria Souza,${sharedPhone},Salvador,certo`,
].join('\n')
const preview = await previewSupporterImportText(payload, coordinator, csv)
const row = preview.sampleRows[0]
expect(row).toBeDefined()
expect(row.status).toBe('telefone_compartilhado')
expect(isPreviewErrorRow(row)).toBe(true)
// The shared-phone row never reaches the staged ok set: confirming an
// all-flagged batch fails closed instead of importing a guessed ficha.
await expect(
confirmSupporterImportRecord(payload, coordinator, {
operatorAttested: true,
importToken: preview.importToken,
}),
).rejects.toThrow(SUPPORTER_IMPORT_BATCH_EMPTY_MESSAGE)
})
it('aborts the confirm fail-closed when the base gains a second ficha for an ok phone (C111)', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents()
const coordinator = await fixtures.createCampaignUser('coordinator')
const phone = fixtures.phone()
await fixtures.createContact({ phones: [{ value: phone }] })
const csv = `nome,telefone,municipio,intencao\nAna Silva,${phone},Salvador,certo`
const preview = await previewSupporterImportText(payload, coordinator, csv)
expect(preview.counts.ok).toBe(1)
// Simulate the race: between preview and confirm another ficha claims the
// same phone. The confirm holds the phone locks and must fail closed.
await fixtures.createContact({ name: 'Segunda Ficha', phones: [{ value: phone }] })
await expect(
confirmSupporterImportRecord(payload, coordinator, {
operatorAttested: true,
importToken: preview.importToken,
}),
).rejects.toThrow(SUPPORTER_IMPORT_SHARED_PHONE_MESSAGE)
})
it('rejects a tampered import token and a token issued to another actor', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents()
const coordinator = await fixtures.createCampaignUser('coordinator')
const otherCoordinator = await fixtures.createCampaignUser('coordinator')
const phoneOk = fixtures.phone()
const csv = `nome,telefone,municipio,intencao\nAna Silva,${phoneOk},Salvador,certo`
const preview = await previewSupporterImportText(payload, coordinator, csv)
expect(preview.counts.ok).toBe(1)
const [batchId, expiresAt, sig] = preview.importToken.split('.')
const tampered = `${batchId}.${expiresAt}.${sig.slice(0, -2)}xx`
await expect(
confirmSupporterImportRecord(payload, coordinator, {
operatorAttested: true,
importToken: tampered,
}),
).rejects.toThrow(/inválido|expirado/)
// Token bound to a different actor cannot be redeemed by another coordinator.
await expect(
confirmSupporterImportRecord(payload, otherCoordinator, {
operatorAttested: true,
importToken: preview.importToken,
}),
).rejects.toThrow(/não encontrado|inválido|expirado/)
// Cleanup: consume the still-valid token so it does not leak into other tests.
await confirmSupporterImportRecord(payload, coordinator, {
operatorAttested: true,
importToken: preview.importToken,
})
const contact = await payload.find({
collection: 'contact',
where: { 'phones.value': { equals: phoneOk } },
depth: 0,
limit: 1,
overrideAccess: true,
})
fixtures.own('contact', contact.docs[0]!.id)
const created = await payload.find({
collection: 'supporter',
where: { contact: { equals: contact.docs[0]!.id } },
depth: 0,
limit: 1,
overrideAccess: true,
})
if (created.docs[0]) fixtures.own('supporter', created.docs[0].id)
})
it('removes supporter and anonymizes contact when no other joins remain', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents()
const coordinator = await fixtures.createCampaignUser('coordinator')
const phone = fixtures.phone()
const created = await createSupporterRecord(payload, coordinator, {
name: fixtures.value('Apoiador'),
phones: [phone],
consentAccepted: true,
})
const contactID = relationId(created.contact)
fixtures.own('supporter', created.id)
fixtures.own('contact', contactID)
const removed = await removeSupporterDataRecord(payload, coordinator, { id: created.id })
expect(removed.removed).toBe(true)
expect(removed.contactAnonymized).toBe(true)
const contact = await payload.findByID({
collection: 'contact',
id: contactID,
depth: 0,
})
expect(contact.name).toBe('Titular removido')
expect(contact.phones?.map((entry) => entry.value)).not.toContain(phone)
})
it('aggregates supporter overview KPIs in a single SQL query for the coordinator', async () => {
const fixtures = campaignFixtures()
const registration = await ensureConsentByKey(
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
)
const coordinator = await fixtures.createCampaignUser('coordinator')
const intentions: Array<{
voteIntention: 'certo' | 'tende_a_certo' | 'indeciso' | 'outro' | null
}> = [
{ voteIntention: 'certo' },
{ voteIntention: 'certo' },
{ voteIntention: 'tende_a_certo' },
{ voteIntention: 'indeciso' },
{ voteIntention: 'indeciso' },
{ voteIntention: 'outro' },
{ voteIntention: null },
]
const searchTag = fixtures.value('OverviewKpi')
for (const { voteIntention } of intentions) {
const contact = await fixtures.createContact({ name: searchTag })
await fixtures.createSupporter({
contact: contact.id,
consent: registration.id,
consentContentHash: 'hash',
consentedAt: new Date().toISOString(),
createdBy: coordinator.id,
...(voteIntention ? { voteIntention } : {}),
})
}
const list = await payload.find({
collection: 'supporter',
where: buildSupporterListWhere({ page: 1, q: searchTag }),
depth: 0,
pagination: false,
user: coordinator,
overrideAccess: false,
})
const overview = await loadSupporterListOverviewData(
payload,
coordinator,
{ page: 1, q: searchTag },
list.totalDocs,
)
expect(overview).not.toBeNull()
expect(overview!.total).toBe(intentions.length)
expect(overview!.certoAndTende).toBe(3)
expect(overview!.indeciso).toBe(2)
// `total` is the caller's contract: a 0 total skips the aggregate query
// entirely and hides the panel, even if rows technically exist.
expect(await loadSupporterListOverviewData(payload, coordinator, { page: 1 }, 0)).toBeNull()
})
it('scopes the overview aggregate to administered municipalities for an advisor and applies filters', async () => {
const fixtures = campaignFixtures()
const registration = await ensureConsentByKey(
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
)
const advisor = await fixtures.createCampaignUser('advisor')
const assigned = await fixtures.getMunicipality()
const other = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(assigned, [advisor])
const makeSupporter = async (
municipalityId: number | undefined,
voteIntention: 'certo' | 'indeciso' | null,
city = 'Salvador',
name = fixtures.value('Apoiador'),
) => {
const contact = await fixtures.createContact({ city, name })
await fixtures.createSupporter({
contact: contact.id,
consent: registration.id,
consentContentHash: 'hash',
consentedAt: new Date().toISOString(),
createdBy: advisor.id,
...(municipalityId ? { municipality: municipalityId } : {}),
...(voteIntention ? { voteIntention } : {}),
})
}
await makeSupporter(assigned.id, 'certo')
await makeSupporter(assigned.id, 'indeciso')
await makeSupporter(assigned.id, null, 'Feira de Santana')
await makeSupporter(other.id, 'certo') // outside scope — must not be counted
const overview = await loadSupporterListOverviewData(payload, advisor, { page: 1 }, 3)
expect(overview).not.toBeNull()
expect(overview!.total).toBe(3)
expect(overview!.certoAndTende).toBe(1)
expect(overview!.indeciso).toBe(1)
const filtered = await loadSupporterListOverviewData(
payload,
advisor,
{ page: 1, city: 'Feira de Santana' },
1,
)
expect(filtered).not.toBeNull()
expect(filtered!.total).toBe(1)
})
it('ignores single-character search queries in list and overview filters', async () => {
const fixtures = campaignFixtures()
const registration = await ensureConsentByKey(
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
)
const coordinator = await fixtures.createCampaignUser('coordinator')
const uniqueName = fixtures.value('ApoiadorUnico')
const contact = await fixtures.createContact({ name: uniqueName })
await fixtures.createSupporter({
contact: contact.id,
consent: registration.id,
consentContentHash: 'hash',
consentedAt: new Date().toISOString(),
createdBy: coordinator.id,
})
const shortQuery = uniqueName.slice(0, 1)
const listWhere = buildSupporterListWhere({ page: 1, q: shortQuery })
const list = await payload.find({
collection: 'supporter',
where: listWhere,
depth: 0,
pagination: false,
user: coordinator,
overrideAccess: false,
})
expect(list.totalDocs).toBeGreaterThanOrEqual(1)
const overview = await loadSupporterListOverviewData(
payload,
coordinator,
{ page: 1, q: shortQuery },
list.totalDocs,
)
expect(overview).not.toBeNull()
expect(overview!.total).toBe(list.totalDocs)
})
it('keeps list and overview totals aligned for multi-character search', async () => {
const fixtures = campaignFixtures()
const registration = await ensureConsentByKey(
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
)
const coordinator = await fixtures.createCampaignUser('coordinator')
const uniqueName = fixtures.value('ApoiadorBusca')
const contact = await fixtures.createContact({ name: uniqueName })
await fixtures.createSupporter({
contact: contact.id,
consent: registration.id,
consentContentHash: 'hash',
consentedAt: new Date().toISOString(),
createdBy: coordinator.id,
})
const q = uniqueName.slice(0, 8)
const list = await payload.find({
collection: 'supporter',
where: buildSupporterListWhere({ page: 1, q }),
depth: 0,
pagination: false,
user: coordinator,
overrideAccess: false,
})
expect(list.totalDocs).toBeGreaterThanOrEqual(1)
const overview = await loadSupporterListOverviewData(
payload,
coordinator,
{ page: 1, q },
list.totalDocs,
)
expect(overview).not.toBeNull()
expect(overview!.total).toBe(list.totalDocs)
expect(overview!.certoAndTende + overview!.indeciso).toBeLessThanOrEqual(list.totalDocs)
})
it('loads supporters page data scoped to the administered municipalities of an advisor', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents()
const advisor = await fixtures.createCampaignUser('advisor')
const assigned = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(assigned, [advisor])
const registration = await ensureConsentByKey(
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
)
const contact = await fixtures.createContact()
await fixtures.createSupporter({
contact: contact.id,
municipality: assigned.id,
consent: registration.id,
consentContentHash: 'hash',
consentedAt: new Date().toISOString(),
createdBy: advisor.id,
})
const pageData = await loadSupportersPageData(payload, advisor, {})
expect(pageData.advisorMunicipalityIds).toContain(assigned.id)
expect(pageData.municipalityOptions.map((option) => option.id)).toEqual([assigned.id])
expect(pageData.result.totalDocs).toBeGreaterThanOrEqual(1)
expect(pageData.overview).not.toBeNull()
})
})