teqo-backup/tests/int/submitPetitionSignature.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

153 lines
4.6 KiB
TypeScript

// @vitest-environment node
/**
* P3-B: the public petition-signature flow resolves the consent SERVER-SIDE
* from the petition's own `form.consent` — a client-posted `consentId` is
* inert (dropped at the input boundary), so a signature always records the
* document whose text the page rendered, never one the caller chose.
*/
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it, vi } from 'vitest'
// The Petition afterChange hook revalidates its ISR tag, which needs the Next
// runtime — neuter it (the revalidation itself is pinned elsewhere).
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: () => undefined,
}))
import { submitPetitionSignature } from '@/app/(frontend)/actions/submitPetitionSignature'
import type { Petition } from '@/payload-types'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const petitionBody: Petition['body'] = {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [{ type: 'text', text: 'Corpo da petição de teste', version: 1 }],
direction: null,
format: '',
indent: 0,
version: 1,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
}
const relationIdOf = (value: unknown): number =>
typeof value === 'object' && value !== null ? (value as { id: number }).id : (value as number)
describe('submitPetitionSignature (server-side consent resolution)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('records the petition consent and ignores a tampered posted consentId', async () => {
const fixtures = campaignFixtures()
const petitionConsent = await fixtures.createConsent()
const otherConsent = await fixtures.createConsent()
const petition = await payload.create({
collection: 'petition',
data: {
id: `peticao-${fixtures.value('p3b')}`,
title: 'Petição de teste P3-B',
subtitle: 'Consentimento resolvido no servidor',
enabled: true,
body: petitionBody,
form: { consent: petitionConsent.id },
_status: 'published',
},
depth: 0,
overrideAccess: true,
})
const phone = fixtures.phone()
const result = await submitPetitionSignature({
name: fixtures.personName('Assinante'),
email: `${fixtures.value('sign')}@example.com`,
phone,
state: 'BA',
city: 'Salvador',
petitionId: String(petition.id),
// Tampered extra field: the input boundary drops it, so it must be inert.
consentId: otherConsent.id,
} as Parameters<typeof submitPetitionSignature>[0] & { consentId: number })
expect(result.ok).toBe(true)
expect(result.signatureNumber).toBeGreaterThan(0)
const contacts = await payload.find({
collection: 'contact',
where: { 'phones.value': { equals: phone } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const contact = contacts.docs[0]
expect(contact).toBeDefined()
const signatures = await payload.find({
collection: 'signature',
where: { contact: { equals: contact!.id } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const signature = signatures.docs[0]
expect(signature).toBeDefined()
expect(relationIdOf(signature!.consent)).toBe(petitionConsent.id)
const subscriptions = await payload.find({
collection: 'subscription',
where: { contact: { equals: contact!.id } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const subscription = subscriptions.docs[0]
expect(subscription).toBeDefined()
expect(relationIdOf(subscription!.consent)).toBe(petitionConsent.id)
// The action's rows are not fixture-owned — delete them here so the
// fixture's contact cleanup does not trip on the FKs; the petition row is
// not tracked by the fixtures either.
await payload.delete({
collection: 'signature',
id: signature!.id,
depth: 0,
overrideAccess: true,
})
await payload.delete({
collection: 'subscription',
id: subscription!.id,
depth: 0,
overrideAccess: true,
})
await payload.delete({
collection: 'petition',
id: petition.id,
depth: 0,
overrideAccess: true,
})
})
})