teqo-backup/tests/int/campaignMultiPhones.int.spec.ts
Francisco Solla 6de8fad8f6 feat(C112): múltiplos telefones por pessoa — Contact.phones array (ordem = prioridade)
- Contact.phone (text único) → phones (array field Payload, subcampo value):
  fonte única; principal = phones[0]; beforeValidate normaliza, filtra vazios,
  rejeita inválido e duplicado DENTRO da ficha (C111 entre fichas preservado)
- migration add_contact_phones_array: backfill phone → phones[0] (gen_random_uuid)
  antes do drop da coluna + índice em value (hand-edit documentado; snapshot .json
  gerado mantém as colunas C99/C100 já existentes)
- primaryPhoneOf / phoneValuesOf / reorderWithPrimaryPhone em src/lib/phone.ts
  (ordem = prioridade; inline edit = seta o principal preservando o resto)
- findOrCreateContactByPhone aceita phones[] (dedupe pelo primário; reuse faz
  append dos faltantes; locks em todos os números digitados)
- sync conta→ficha (CampaignUser): phone da conta faz APPEND quando a ficha não
  tem (nunca reordena nem remove); tombstone LGPD substitui TODOS os telefones
- convite redemption: telefone digitado vira principal, demais preservados
- import CSV: preview sinaliza compartilhado também por número secundário;
  bulk insere contact_phones (JOIN nas leituras, uuid no insert)
- busca por telefone (apoiadores, pickers, combobox) casa qualquer número
- UI: PhonesFieldEditor compartilhado (repetível mascarado, remover, ↑/↓,
  modo inline com Salvar) nas 5 superfícies — liderança (form/ficha/lista),
  apoiador (form/ficha), dobradinha (ficha), assessor (ficha), wizard
- UI pública (petição/WhatsApp) intocada: zod e forms ficam com phone;
  as ações mapeiam phone → phones[0]
- testes: spec novo campaignMultiPhones (aceite completo), specs C111/C99
  atualizados, e2e wizard/apoiador ajustados
2026-08-11 10:47:02 -03:00

458 lines
16 KiB
TypeScript

// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import type { CampaignUser } from '@/payload-types'
const authState = vi.hoisted(() => ({
user: null as CampaignUser | null,
}))
const requestHeadersState = vi.hoisted(() => ({
value: new Headers({ origin: 'http://localhost:3212' }),
}))
vi.mock('@/utilities/campaignAuth', () => ({
getCampaignUser: async () => authState.user,
}))
vi.mock('next/headers', () => ({
headers: async () => requestHeadersState.value,
}))
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}))
import {
createLeadershipRecord,
updateLeadershipContactRecord,
} from '@/app/(campaign)/campanha/actions/leadership'
import {
createSupporterRecord,
updateSupporterContactRecord,
} from '@/app/(campaign)/campanha/actions/supporter'
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import {
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
BRAZILIAN_PHONE_INVALID_MESSAGE,
primaryPhoneOf,
reorderWithPrimaryPhone,
} from '@/lib/phone'
import config from '@/payload.config'
import { toSupporterDetailViewModel } from '@/utilities/supporter/supporterViewModels'
import {
ensureLeasedConsent,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
} from '../helpers/testDatabaseLease'
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('C112 — múltiplos telefones por pessoa', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('keeps order = priority through the pure helpers', () => {
expect(primaryPhoneOf(null)).toBeNull()
expect(primaryPhoneOf([])).toBeNull()
expect(primaryPhoneOf([{ value: '71911110000' }, { value: '71922220000' }])).toBe('71911110000')
expect(reorderWithPrimaryPhone([], '71911110000')).toEqual(['71911110000'])
expect(
reorderWithPrimaryPhone([{ value: '71911110000' }, { value: '71922220000' }], '71922220000'),
).toEqual(['71922220000', '71911110000'])
expect(reorderWithPrimaryPhone([{ value: '71911110000' }], null)).toEqual([])
})
it('creates a ficha with N phones on leadership create and lists the primary', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const primary = campaignFixtures().phone()
const secondary = campaignFixtures().phone()
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança Multi Telefones',
phones: [primary, secondary],
supportStatus: 'engajado',
})
const contact = await payload.findByID({
collection: 'contact',
id: relationId(created.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phones?.map((entry) => entry.value)).toEqual([primary, secondary])
const summary = await payload.find({
collection: 'leadership',
where: { id: { equals: created.id } },
depth: 1,
limit: 1,
pagination: false,
user: coordinator,
overrideAccess: false,
})
const contactSummary = summary.docs[0]?.contact
expect(
typeof contactSummary === 'object' && contactSummary !== null
? primaryPhoneOf(contactSummary.phones)
: null,
).toBe(primary)
})
it('rejects the same number twice in one ficha (dedupe within the person)', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const phone = campaignFixtures().phone()
await expect(
createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança Repetida',
phones: [phone, phone],
supportStatus: 'engajado',
}),
).rejects.toThrow(BRAZILIAN_PHONE_DUPLICATE_MESSAGE)
await expect(
payload.create({
collection: 'contact',
data: {
name: 'Ficha Repetida',
phones: [{ value: phone }, { value: phone }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
overrideAccess: true,
}),
).rejects.toThrow(BRAZILIAN_PHONE_DUPLICATE_MESSAGE)
// The invalid-number refusal is unchanged.
await expect(
payload.create({
collection: 'contact',
data: {
name: 'Ficha Inválida',
phones: [{ value: '1199999999' }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
overrideAccess: true,
}),
).rejects.toThrow(BRAZILIAN_PHONE_INVALID_MESSAGE)
})
it('sets the primary phone preserving the rest through the inline edit', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const first = campaignFixtures().phone()
const second = campaignFixtures().phone()
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança Troca Principal',
phones: [first, second],
supportStatus: 'engajado',
})
const updated = await updateLeadershipContactRecord(payload, coordinator, {
id: created.id,
field: 'phone',
phone: second,
})
const contact = await payload.findByID({
collection: 'contact',
id: relationId(updated.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phones?.map((entry) => entry.value)).toEqual([second, first])
// Clearing the primary removes it — the rest shifts up.
await updateLeadershipContactRecord(payload, coordinator, {
id: created.id,
field: 'phone',
phone: null,
})
const afterClear = await payload.findByID({
collection: 'contact',
id: relationId(created.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(afterClear.phones?.map((entry) => entry.value)).toEqual([first])
})
it('writes the full list through the phones branch (ficha editor)', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança Editor',
phones: [campaignFixtures().phone()],
supportStatus: 'engajado',
})
const kept = campaignFixtures().phone()
const added = campaignFixtures().phone()
const updated = await updateLeadershipContactRecord(payload, coordinator, {
id: created.id,
field: 'phones',
phones: [kept, added],
})
const contact = await payload.findByID({
collection: 'contact',
id: relationId(updated.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phones?.map((entry) => entry.value)).toEqual([kept, added])
})
it('supporter detail shows every number and the list still shows the primary', async () => {
const fixtures = campaignFixtures()
await ensureLeasedConsent(payload, {
consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
})
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const primary = fixtures.phone()
const secondary = fixtures.phone()
const created = await createSupporterRecord(payload, coordinator, {
name: fixtures.value('Apoiador Multi Telefones'),
phones: [primary, secondary],
municipality: municipality.id,
city: 'Salvador',
consentAccepted: true,
})
const supporter = await payload.findByID({
collection: 'supporter',
id: created.id,
depth: 1,
overrideAccess: true,
})
const detail = toSupporterDetailViewModel(supporter)
expect(detail.phones).toEqual([primary, secondary])
expect(detail.phone).toBe(primary)
// The supporter ficha editor (phones branch) replaces the whole list.
const third = fixtures.phone()
const updated = await updateSupporterContactRecord(payload, coordinator, {
id: created.id,
field: 'phones',
phones: [third, primary],
})
const contact = await payload.findByID({
collection: 'contact',
id: relationId(updated.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phones?.map((entry) => entry.value)).toEqual([third, primary])
})
})
describe('C112 — busca e convite com números secundários', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('finds the supporter by a NON-primary number (payload and SQL search paths)', async () => {
const fixtures = campaignFixtures()
await ensureLeasedConsent(payload, {
consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
})
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const primary = fixtures.phone()
const secondary = fixtures.phone()
const created = await createSupporterRecord(payload, coordinator, {
name: fixtures.value('Apoiador Busca Secundária'),
phones: [primary, secondary],
municipality: municipality.id,
city: 'Salvador',
consentAccepted: true,
})
// Payload where path: the list search terms normalize to the full number.
const { toPayloadWhere } = await import('@/utilities/supporter/supporterListFilters')
const where = toPayloadWhere({ q: secondary })
const hits = await payload.find({
collection: 'supporter',
where,
depth: 1,
limit: 10,
pagination: false,
overrideAccess: true,
})
expect(hits.docs.map((doc) => doc.id)).toContain(created.id)
// SQL aggregate path: the raw-conditions builder must match a secondary too.
const { toAggregateSqlConditions } =
await import('@/utilities/supporter/supporterListSqlFilters')
const sql = await import('@payloadcms/db-postgres')
const { conditions, needsContactJoin } = toAggregateSqlConditions({
q: secondary,
})
expect(needsContactJoin).toBe(true)
expect(conditions.length).toBeGreaterThan(0)
const conditionSql = sql.sql.join(conditions, sql.sql` AND `)
const result = await payload.db.drizzle.execute(
sql.sql`SELECT "supporter"."id" FROM "supporter"
JOIN "contact" ON "contact"."id" = "supporter"."contact_id"
WHERE ${conditionSql}`,
)
const rows = Array.isArray(result)
? result
: ((result as { rows: unknown }).rows as Array<{ id: number }>)
const ids = rows.map((row) => row.id)
expect(ids).toContain(created.id)
})
it('builds the invite WhatsApp link with the PRIMARY of a multi-phone ficha', async () => {
const { buildCampaignInviteWhatsAppLink } = await import('@/utilities/campaignInvite')
const primary = campaignFixtures().phone()
const secondary = campaignFixtures().phone()
const url = buildCampaignInviteWhatsAppLink({
phone: primary,
recipientName: 'Liderança',
senderName: 'Coordenação',
inviteUrl: 'https://pt.jorgesolla.com.br/campanha/convite/abc',
kind: 'autopreenchimento',
})
expect(url).toContain(`wa.me/55${primary}`)
expect(url).not.toContain(`wa.me/55${secondary}`)
// And the invitation creation gate uses the ficha's primary.
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: campaignFixtures().value('Liderança Convite Multi'),
phones: [primary, secondary],
supportStatus: 'engajado',
})
const { createCampaignInvite } = await import('@/app/(campaign)/campanha/actions/invite')
authState.user = coordinator
const invite = await createCampaignInvite({
leadership: created.id,
kind: 'autopreenchimento',
})
expect(invite.whatsappUrl).toContain(`wa.me/55${primary}`)
})
})
describe('C112 — dobradinha e assessor com N telefones', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('writes the dobradinha ficha list through the phones branch', async () => {
const { updateStateDeputyContactRecord } =
await import('@/app/(campaign)/campanha/actions/stateDeputy')
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({
name: `${fixtures.value('Dobradinha')} Multi`,
})
const first = fixtures.phone()
const second = fixtures.phone()
const updated = await updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'phones',
phones: [first, second],
})
const contact = await payload.findByID({
collection: 'contact',
id: relationId(updated.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phones?.map((entry) => entry.value)).toEqual([first, second])
})
it('writes the advisor ficha list and rejects duplicates', async () => {
const { updateAdvisorContactFicha } = await import('@/app/(campaign)/campanha/actions/advisor')
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
authState.user = coordinator
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessora Multi Telefones'),
})
const first = fixtures.phone()
const second = fixtures.phone()
const contactID = relationId(
await payload
.findByID({
collection: 'campaignUser',
id: advisor.id,
depth: 0,
select: { contact: true },
overrideAccess: true,
})
.then((account) => account.contact),
)
expect(contactID).not.toBeNull()
await updateAdvisorContactFicha({ contactId: contactID!, phones: [first, second] })
const contact = await payload.findByID({
collection: 'contact',
id: contactID!,
depth: 0,
overrideAccess: true,
})
expect(contact.phones?.map((entry) => entry.value)).toEqual([first, second])
await expect(
updateAdvisorContactFicha({ contactId: contactID!, phones: [first, first] }),
).rejects.toThrow(BRAZILIAN_PHONE_DUPLICATE_MESSAGE)
})
})
describe('C112 — CSV import com telefone compartilhado como secundário', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('flags a CSV phone carried as SECONDARY by two fichas (preview = confirm fail-closed)', async () => {
const fixtures = campaignFixtures()
await ensureLeasedConsent(payload, {
consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
})
const coordinator = await fixtures.createCampaignUser('coordinator')
const sharedSecondary = fixtures.phone()
// Two fichas whose PRIMARY differs but which both carry the CSV number.
await fixtures.createContact({
phones: [{ value: fixtures.phone() }, { value: sharedSecondary }],
})
await fixtures.createContact({
phones: [{ value: fixtures.phone() }, { value: sharedSecondary }],
})
const { previewSupporterImportText } =
await import('@/app/(campaign)/campanha/actions/supporterImport')
const csv = `nome,telefone,municipio,intencao\nMaria Souza,${sharedSecondary},Salvador,certo`
const preview = await previewSupporterImportText(payload, coordinator, csv)
expect(preview.counts.ok).toBe(0)
expect(preview.sampleRows.some((row) => row.status === 'telefone_compartilhado')).toBe(true)
})
})