teqo/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts
Francisco Solla 162a518416 feat(C111): telefone não único — duas pessoas podem compartilhar o mesmo celular
Contact.phone deixa de ser único em enforcement (nunca houve constraint DB):
- remove o hook enforceUniqueContactPhone e os asserts assertContactPhone*
  (conflicts/ambiguity) — os 5 fluxos de escrita de pessoa salvam com número
  compartilhado; rename contactPhoneInvariant.ts -> contactPhoneLocks.ts
  (locks passam a significar serialização, não unicidade)
- findOrCreateContactByPhone: 0 fichas cria, 1 reusa (dedupe C6/C99),
  2+ cria ficha nova (a identidade da pessoa em criação é a ficha nova)
- convite autofill/login: ficha ancorada pode compartilhar o phone; o lado
  conta permanece fail-closed (username DB-unique; check de 2+ contas)
- import CSV: preview sinaliza telefone_compartilhado (resolução manual);
  confirm/bulk abortam fail-closed se um phone ganhar 2+ fichas (race)
- fix de achado de execução: supporter_import_batch.actor_id NOT NULL com
  FK ON DELETE set null derrubava o delete de campaignUser com batch
  órfão (23502) — CampaignUser.beforeDelete limpa batches + fixture
  cleanup descobre/deleta batches dos accounts owned
2026-08-10 23:03:05 -03:00

322 lines
11 KiB
TypeScript

// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import {
createMunicipalityStateDeputyRecord,
updateStateDeputyContactRecord,
updateStateDeputyPartyRecord,
} from '@/app/(campaign)/campanha/actions/stateDeputy'
import { STATE_DEPUTY_CONFLICT_MESSAGE } from '@/lib/schemas/stateDeputy'
import config from '@/payload.config'
import { installCampaignFixtures, relationId, relationIds } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const stateDeputyIdsOf = async (municipalityID: number): Promise<number[]> => {
const municipality = await payload.findByID({
collection: 'municipality',
id: municipalityID,
depth: 0,
select: { stateDeputies: true },
overrideAccess: true,
})
return relationIds(municipality.stateDeputies)
}
const stateDeputyByName = async (name: string) => {
const result = await payload.find({
collection: 'stateDeputy',
where: { 'contact.name': { equals: name } },
depth: 1,
limit: 1,
pagination: false,
overrideAccess: true,
})
return result.docs[0]
}
describe('createMunicipalityStateDeputyRecord (B157)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it.each(['coordinator', 'candidate'] as const)(
'lets a %s create a dobradinha and link it to the município in one write',
async (role) => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser(role)
const municipality = await fixtures.getMunicipality()
const name = fixtures.value('Cicrano')
const { stateDeputy, municipalitySlug } = await createMunicipalityStateDeputyRecord(
payload,
actor,
{ municipalityId: municipality.id, rawName: name },
)
fixtures.touchMunicipality(municipality.id)
expect(municipalitySlug).toBe(municipality.slug)
expect((await stateDeputyByName(name))?.contact).toMatchObject({ name })
expect(stateDeputy.party).toBeNull()
expect(await stateDeputyIdsOf(municipality.id)).toEqual([stateDeputy.id])
},
)
it('parses a trailing (PARTIDO) group and persists the party', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const name = fixtures.value('Cicrano')
const party = fixtures
.value('PCdoB')
.replace(/-\w+-\d+$/, '')
.slice(0, 32)
const { stateDeputy } = await createMunicipalityStateDeputyRecord(payload, coordinator, {
municipalityId: municipality.id,
rawName: `${name} (${party})`,
})
fixtures.touchMunicipality(municipality.id)
expect((await stateDeputyByName(name))?.contact).toMatchObject({ name })
expect(stateDeputy.party).toBe(party)
expect((await stateDeputyByName(name))?.party).toBe(party)
})
it('refuses a name that only parses to a party group', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await expect(
createMunicipalityStateDeputyRecord(payload, coordinator, {
municipalityId: municipality.id,
rawName: '(PT)',
}),
).rejects.toThrow(/entre 2 e 120/)
expect(await stateDeputyIdsOf(municipality.id)).toEqual([])
})
it('maps a duplicate name to the safe conflict message', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const existingName = fixtures.value('Deputado')
await fixtures.createStateDeputy({ name: existingName })
await expect(
createMunicipalityStateDeputyRecord(payload, coordinator, {
municipalityId: municipality.id,
rawName: existingName,
}),
).rejects.toThrow(STATE_DEPUTY_CONFLICT_MESSAGE)
expect(await stateDeputyIdsOf(municipality.id)).toEqual([])
})
it('rolls the create back when the assign is out of the actor scope', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const outside = await fixtures.getMunicipality()
const name = fixtures.value('Fora do escopo')
await expect(
createMunicipalityStateDeputyRecord(payload, advisor, {
municipalityId: outside.id,
rawName: name,
}),
).rejects.toThrow()
// The create must roll back with the failed assign — no orphan deputy.
expect(await stateDeputyByName(name)).toBeUndefined()
const orphanContacts = await payload.find({
collection: 'contact',
where: { name: { equals: name } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
expect(orphanContacts.totalDocs).toBe(0)
expect(await stateDeputyIdsOf(outside.id)).toEqual([])
})
it('denies a leader actor', async () => {
const fixtures = campaignFixtures()
const leaderActor = await fixtures.createCampaignUser('leader')
const municipality = await fixtures.getMunicipality()
const name = fixtures.value('Só staff')
await expect(
createMunicipalityStateDeputyRecord(payload, leaderActor, {
municipalityId: municipality.id,
rawName: name,
}),
).rejects.toThrow(/coordenação e a assessoria/i)
expect(await stateDeputyByName(name)).toBeUndefined()
})
it('serializes concurrent creates on the same município through the advisory lock', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const [first, second] = await Promise.all([
createMunicipalityStateDeputyRecord(payload, coordinator, {
municipalityId: municipality.id,
rawName: fixtures.value('Primeiro'),
}),
createMunicipalityStateDeputyRecord(payload, coordinator, {
municipalityId: municipality.id,
rawName: fixtures.value('Segundo'),
}),
])
fixtures.touchMunicipality(municipality.id)
expect((await stateDeputyIdsOf(municipality.id)).sort((a, b) => a - b)).toEqual(
[first.stateDeputy.id, second.stateDeputy.id].sort((a, b) => a - b),
)
})
})
describe('StateDeputy contact fields (B163)', () => {
it('updates Contact fields and party without changing the legacy slug', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const originalName = `${fixtures.value('Nome')} Original`
const correctedName = `${fixtures.value('Nome')} Corrigido`
const stateDeputy = await fixtures.createStateDeputy({ name: originalName, party: 'PT' })
const legacySlug = stateDeputy.slug
const phone = fixtures.phone()
await updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'name',
name: correctedName,
})
await updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'email',
email: 'corrigido@example.com',
})
await updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'phone',
phone,
})
await updateStateDeputyPartyRecord(payload, coordinator, {
id: stateDeputy.id,
party: 'PSD',
})
const updated = await payload.findByID({
collection: 'stateDeputy',
id: stateDeputy.id,
depth: 1,
overrideAccess: true,
})
expect(updated.slug).toBe(legacySlug)
expect(updated.party).toBe('PSD')
expect(updated.contact).toMatchObject({
name: correctedName,
email: 'corrigido@example.com',
phone,
})
})
it('shares a phone with another Contact (C111)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({
name: `${fixtures.value('Dobradinha')} Principal`,
})
const takenPhone = fixtures.phone()
await fixtures.createContact({ phone: takenPhone })
const updated = await updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'phone',
phone: takenPhone,
})
const contact = await payload.findByID({
collection: 'contact',
id: relationId(updated.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phone).toBe(takenPhone)
const withPhone = await payload.find({
collection: 'contact',
where: { phone: { equals: takenPhone } },
depth: 0,
limit: 2,
pagination: false,
overrideAccess: true,
})
expect(withPhone.totalDocs).toBe(2)
})
it('rejects renaming a dobradinha to another dobradinha name', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const usedName = `${fixtures.value('Nome')} já usado`
const currentName = `${fixtures.value('Nome')} atual`
await fixtures.createStateDeputy({ name: usedName })
const stateDeputy = await fixtures.createStateDeputy({ name: currentName })
await expect(
updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'name',
name: usedName,
}),
).rejects.toThrow(STATE_DEPUTY_CONFLICT_MESSAGE)
const unchanged = await payload.findByID({
collection: 'stateDeputy',
id: stateDeputy.id,
depth: 1,
overrideAccess: true,
})
expect(unchanged.contact).toMatchObject({ name: currentName })
})
it('keeps the name invariant when an admin edits the linked Contact directly', async () => {
const fixtures = campaignFixtures()
const usedName = `${fixtures.value('Nome')} já usado`
const currentName = `${fixtures.value('Nome')} atual`
const first = await fixtures.createStateDeputy({ name: usedName })
const second = await fixtures.createStateDeputy({ name: currentName })
const secondContactID = typeof second.contact === 'object' ? second.contact.id : second.contact
await expect(
payload.update({
collection: 'contact',
id: secondContactID,
data: { name: usedName },
depth: 0,
overrideAccess: true,
}),
).rejects.toThrow(STATE_DEPUTY_CONFLICT_MESSAGE)
const unchanged = await payload.findByID({
collection: 'contact',
id: secondContactID,
depth: 0,
overrideAccess: true,
})
expect(unchanged.name).toBe(currentName)
expect(first.id).not.toBe(second.id)
})
})