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

478 lines
16 KiB
TypeScript

// @vitest-environment node
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { getPayload, type Payload } from 'payload'
import { createElement, type ReactElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeAll, describe, expect, it } from 'vitest'
import {
redeemCampaignInviteAutofillFormAction,
redeemCampaignInviteLoginFormAction,
} from '@/app/(campaign)/campanha/convite/[token]/formActions'
import { CampaignInviteForm } from '@/components/campaign/invite/CampaignInviteForm'
import { InvalidCampaignInvite } from '@/components/campaign/invite/InvalidCampaignInvite'
import { ConsentText } from '@/components/campaign/shared/ConsentText'
import type { Consent } from '@/payload-types'
import config from '@/payload.config'
import { hashCampaignInviteToken } from '@/utilities/campaignInvite'
import {
getCampaignInviteConsentState,
getCampaignInvitePageData,
} from '@/utilities/campaignInvitePageData'
import { hashConsentContent } from '@/utilities/consentContentHash'
import { withInviteConsent } from '../helpers/testDatabaseLease'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
let activeInviteConsent: Consent | undefined
const consentText = {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [{ type: 'text', text: 'Consentimento configurado', version: 1 }],
direction: null,
format: '',
indent: 0,
version: 1,
},
{
type: 'list',
children: [
{
type: 'listitem',
children: [
{
type: 'text',
text: 'Uso limitado à campanha',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
value: 1,
version: 1,
},
],
direction: null,
format: '',
indent: 0,
listType: 'bullet',
start: 1,
tag: 'ul',
version: 1,
},
{
type: 'paragraph',
children: [
{
type: 'link',
children: [{ type: 'text', text: 'Política de privacidade', version: 1 }],
direction: null,
fields: {
linkType: 'custom',
newTab: true,
url: 'https://example.com/privacidade',
},
format: '',
indent: 0,
version: 3,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
}
// Builds the complete public invite graph, including optional consent and terminal state.
const createPublicInviteScenario = async (
kind: 'login' | 'autopreenchimento',
options: { used?: boolean; expired?: boolean; consent?: boolean } = {},
) => {
const consent = activeInviteConsent
if (!consent) throw new Error('Invite consent fixture scope is missing.')
const actor = await payload.create({
collection: 'campaignUser',
data: {
name: 'Coordenação de teste',
email: `${campaignFixtures().value('invite-ui')}@example.com`,
password: campaignFixtures().value('password'),
role: 'coordinator',
},
depth: 0,
})
const municipality = await campaignFixtures().getMunicipality()
const contact = await payload.create({
collection: 'contact',
data: {
name: 'Maria da Conceição',
phones: [{ value: campaignFixtures().phone() }],
email: 'maria@example.com',
state: 'BA',
city: 'Salvador',
gender: 'feminino',
},
depth: 0,
})
const leadership = await payload.create({
collection: 'leadership',
data: {
contact: contact.id,
municipalities: [municipality.id],
supportStatus: 'engajado',
notes: 'Avaliação interna protegida',
consent: options.consent ? consent.id : null,
consentContentHash: options.consent ? hashConsentContent(consent.text) : null,
consentedAt: options.consent ? new Date().toISOString() : null,
createdBy: actor.id,
},
depth: 0,
})
const token = campaignFixtures().value('raw-public-token-long-enough')
await payload.create({
collection: 'campaignInvite',
data: {
tokenHash: hashCampaignInviteToken(token),
leadership: leadership.id,
kind,
expiresAt: new Date(Date.now() + (options.expired ? -60_000 : 60_000)).toISOString(),
usedAt: options.used ? new Date().toISOString() : null,
createdBy: actor.id,
},
depth: 0,
})
return { token, contact, leadership }
}
const itWithInviteConsent = (name: string, test: (consent: Consent) => Promise<void>) =>
it(name, () =>
withInviteConsent(payload, async (consent) => {
activeInviteConsent = consent
try {
await test(consent)
} finally {
activeInviteConsent = undefined
}
}),
)
describe('campaign invite UI contracts', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('keeps consent rendering and invalid-state markup outside the client module graph', () => {
const clientSource = readFileSync(
resolve(process.cwd(), 'src/components/campaign/invite/CampaignInviteForm.tsx'),
'utf8',
)
const consentSource = readFileSync(
resolve(process.cwd(), 'src/components/campaign/shared/ConsentText.tsx'),
'utf8',
)
const invalidSource = readFileSync(
resolve(process.cwd(), 'src/components/campaign/invite/InvalidCampaignInvite.tsx'),
'utf8',
)
expect(clientSource).toMatch(/^'use client'/)
expect(clientSource).not.toMatch(/@payloadcms\/richtext-lexical|RichText|consentData/)
expect(clientSource).not.toContain('campaignInvitePageData')
expect(consentSource).not.toContain('use client')
expect(consentSource).not.toContain('@payloadcms/richtext-lexical')
expect(invalidSource).not.toContain('use client')
})
itWithInviteConsent(
'loads only the whitelisted public preview after hashing the token',
async () => {
const fixture = await createPublicInviteScenario('autopreenchimento')
const preview = await getCampaignInvitePageData(payload, fixture.token)
const serialized = JSON.stringify(preview)
expect(preview).toEqual({
status: 'valid',
kind: 'autopreenchimento',
profile: {
name: fixture.contact.name,
phone: fixture.contact.phones?.[0]?.value ?? '',
email: fixture.contact.email,
gender: fixture.contact.gender,
},
requiresConsent: true,
consentData: expect.objectContaining({ root: expect.any(Object) }),
})
expect(serialized).not.toMatch(
/supportStatus|Avaliação interna|Registro interno|tokenHash|leadershipId|municipality|user/,
)
},
)
itWithInviteConsent('makes nonexistent, used, and expired tokens indistinguishable', async () => {
const used = await createPublicInviteScenario('autopreenchimento', { used: true })
const expired = await createPublicInviteScenario('autopreenchimento', { expired: true })
const states = await Promise.all([
getCampaignInvitePageData(payload, campaignFixtures().value('unknown-token-long-enough')),
getCampaignInvitePageData(payload, used.token),
getCampaignInvitePageData(payload, expired.token),
])
expect(states).toEqual([{ status: 'invalid' }, { status: 'invalid' }, { status: 'invalid' }])
const html = renderToStaticMarkup(createElement(InvalidCampaignInvite))
expect(html).toContain('Este convite não está disponível')
expect(html).not.toMatch(/usado|expirado|inexistente/)
})
itWithInviteConsent(
'requires acceptance only when the accepted consent differs from the configured one',
async (consent) => {
const current = await createPublicInviteScenario('login', { consent: true })
await expect(getCampaignInvitePageData(payload, current.token)).resolves.toMatchObject({
status: 'valid',
requiresConsent: false,
})
const oldConsent = await campaignFixtures().createConsent({
key: null,
text: {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [{ type: 'text', text: 'Versão anterior', version: 1 }],
direction: null,
format: '',
indent: 0,
version: 1,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
},
})
await payload.update({
collection: 'leadership',
id: current.leadership.id,
data: { consent: oldConsent.id },
})
await expect(getCampaignInvitePageData(payload, current.token)).resolves.toMatchObject({
status: 'valid',
requiresConsent: true,
consentData: consent.text,
})
},
)
it('reports whether the required consent is configured without exposing it to staff UI', async () => {
const missingConsentPayload = {
find: async () => ({ docs: [] }),
} as unknown as Payload
const configuredConsentPayload = {
find: async () => ({ docs: [{ id: 1 }] }),
} as unknown as Payload
await expect(getCampaignInviteConsentState(missingConsentPayload)).resolves.toEqual({
configured: false,
})
await expect(getCampaignInviteConsentState(configuredConsentPayload)).resolves.toEqual({
configured: true,
})
})
itWithInviteConsent(
'renders a minimal accessible public form without internal fields',
async () => {
const fixture = await createPublicInviteScenario('login')
const preview = await getCampaignInvitePageData(payload, fixture.token)
if (preview.status !== 'valid') throw new Error('Expected a valid preview.')
const html = renderToStaticMarkup(
createElement(
CampaignInviteForm,
{
action: async () => ({}),
kind: preview.kind,
profile: preview.profile,
requiresConsent: preview.requiresConsent,
},
createElement(ConsentText, {
data: consentText as typeof preview.consentData,
}),
),
)
expect(html).toContain('Oi, Maria da Conceição!')
expect(html).toContain('name="name"')
expect(html).toContain('name="phone"')
expect(html).toContain('name="password"')
expect(html).toContain('name="passwordConfirmation"')
expect(html).toContain('Ler o texto completo')
expect(html).toContain('<ul')
expect(html).toContain('<li')
expect(html).toContain('href="https://example.com/privacidade"')
expect(html).toContain('aria-describedby')
expect(html).not.toContain(fixture.token)
expect(html).not.toContain('name="token"')
expect(html).not.toContain('name="supportStatus"')
expect(html).not.toContain('name="notes"')
expect(html).not.toContain('name="consentNote"')
expect(html).not.toContain('tokenHash')
expect(html).not.toContain('name="municipalities"')
},
)
itWithInviteConsent(
'returns structured form errors and does not consume a login invite on mismatch',
async () => {
const fixture = await createPublicInviteScenario('login')
const formData = new FormData()
formData.set('name', fixture.contact.name)
formData.set('phone', fixture.contact.phones?.[0]?.value ?? '')
formData.set('email', fixture.contact.email ?? '')
formData.set('password', 'senha-segura')
formData.set('passwordConfirmation', 'senha-diferente')
formData.set('consentAccepted', 'on')
await expect(
redeemCampaignInviteLoginFormAction(fixture.token, {}, formData),
).resolves.toEqual({
fieldErrors: {
passwordConfirmation: ['As senhas não coincidem.'],
},
})
await expect(getCampaignInvitePageData(payload, fixture.token)).resolves.toMatchObject({
status: 'valid',
})
},
)
itWithInviteConsent('compares password confirmation without trimming either secret', async () => {
const fixture = await createPublicInviteScenario('login')
const formData = new FormData()
formData.set('name', fixture.contact.name)
formData.set('phone', fixture.contact.phones?.[0]?.value ?? '')
formData.set('email', fixture.contact.email ?? '')
formData.set('password', ' senha-segura ')
formData.set('passwordConfirmation', 'senha-segura')
formData.set('consentAccepted', 'on')
await expect(redeemCampaignInviteLoginFormAction(fixture.token, {}, formData)).resolves.toEqual(
{
fieldErrors: {
passwordConfirmation: ['As senhas não coincidem.'],
},
},
)
await expect(getCampaignInvitePageData(payload, fixture.token)).resolves.toMatchObject({
status: 'valid',
})
})
itWithInviteConsent(
'redeems autofill through the real form action and returns a minimal success state',
async () => {
const fixture = await createPublicInviteScenario('autopreenchimento')
const formData = new FormData()
formData.set('name', 'Maria Atualizada')
formData.set('phone', fixture.contact.phones?.[0]?.value ?? '')
formData.set('email', fixture.contact.email ?? '')
formData.set('gender', 'feminino')
formData.set('consentAccepted', 'on')
await expect(
redeemCampaignInviteAutofillFormAction(fixture.token, {}, formData),
).resolves.toEqual({
status: 'success',
message: 'Seus dados foram confirmados com sucesso.',
})
await expect(getCampaignInvitePageData(payload, fixture.token)).resolves.toEqual({
status: 'invalid',
})
},
)
itWithInviteConsent('renders the actual route without serializing its bearer token', async () => {
const fixture = await createPublicInviteScenario('login')
const route = await import('@/app/(campaign)/campanha/convite/[token]/page')
const page = await route.default({ params: Promise.resolve({ token: fixture.token }) })
const html = renderToStaticMarkup(page)
const inviteFormElement = (page as ReactElement<{ children: ReactElement }>).props
.children as ReactElement<Record<string, unknown>>
expect(route.dynamic).toBe('force-dynamic')
expect(route.fetchCache).toBe('force-no-store')
expect(route.revalidate).toBe(0)
expect(route.metadata).toMatchObject({
referrer: 'no-referrer',
robots: {
index: false,
follow: false,
nocache: true,
},
})
expect(html).toContain('Oi, Maria da Conceição!')
expect(html).not.toContain(fixture.token)
expect(html).not.toContain('name="token"')
expect(Object.keys(inviteFormElement.props).sort()).toEqual([
'action',
'children',
'kind',
'profile',
'requiresConsent',
])
const serializedClientProps = JSON.stringify(inviteFormElement.props)
expect(serializedClientProps).not.toContain(fixture.token)
expect(serializedClientProps).not.toContain('"type":"root"')
expect(serializedClientProps).not.toContain('"version":1')
})
itWithInviteConsent(
'renders the same actual route response for unknown, used, and expired tokens',
async () => {
const route = await import('@/app/(campaign)/campanha/convite/[token]/page')
const used = await createPublicInviteScenario('autopreenchimento', { used: true })
const expired = await createPublicInviteScenario('autopreenchimento', { expired: true })
const tokens = [
campaignFixtures().value('unknown-token-long-enough'),
used.token,
expired.token,
]
const responses = await Promise.all(
tokens.map(async (token) =>
renderToStaticMarkup(await route.default({ params: Promise.resolve({ token }) })),
),
)
expect(new Set(responses)).toHaveLength(1)
expect(responses[0]).toContain('Este convite não está disponível')
},
)
})