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

258 lines
9.1 KiB
TypeScript

// @vitest-environment node
import {
getPayload,
type Field,
type FieldAccess,
type Payload,
type PayloadRequest,
} from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { CampaignUser as CampaignUserCollection } from '@/collections/CampaignUser'
import type { CampaignUser } from '@/payload-types'
import config from '@/payload.config'
import { withCampaignFixtures, type CampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
type FieldAccessArgs = Parameters<FieldAccess>[0]
let payload: Payload
// Builds the engaged Contact → Leadership link that grants contextual phone access.
const createEngagedPhoneAccessGraph = async (
fixtures: CampaignFixtures,
coordinator: CampaignUser,
leader: CampaignUser,
municipality: number,
) => {
const contact = await fixtures.createContact({
name: fixtures.value('Contato telefone'),
})
return fixtures.createLeadership({
contact,
municipalities: [municipality],
user: leader,
supportStatus: 'engajado',
createdBy: coordinator,
})
}
const phoneField = (): Extract<Field, { name: string }> => {
const field = CampaignUserCollection.fields.find(
(candidate): candidate is Extract<Field, { name: string }> =>
'name' in candidate && candidate.name === 'phone',
)
if (!field) throw new Error('Campo phone não configurado.')
return field
}
describe('campaign user contact phone', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('normalizes the optional non-auth contact phone', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const user = await fixtures.createCampaignUser('advisor', {
phone: '+55 (71) 99999-1234',
})
expect(user.phone).toBe('71999991234')
expect(user.username).toBeNull()
expect(phoneField()).toMatchObject({
name: 'phone',
label: 'Celular de contato',
type: 'text',
index: true,
})
})
})
it('omits advisor contact phones from leader reads (lockdown)', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisorPhone = fixtures.phone()
const advisor = await fixtures.createCampaignUser('advisor', {
phone: advisorPhone,
})
const leader = await fixtures.createCampaignUser('leader', {
phone: fixtures.phone(),
})
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
await createEngagedPhoneAccessGraph(fixtures, coordinator, leader, municipality.id)
// Leaders are lockdown accounts: even for advisors of their own linked
// municipality, the phone field must be withheld.
const advisorRead = await payload.findByID({
collection: 'campaignUser',
id: advisor.id,
depth: 0,
select: { name: true, phone: true },
user: leader,
overrideAccess: false,
})
expect(advisorRead.phone).toBeUndefined()
expect(JSON.stringify(advisorRead)).not.toContain(advisorPhone)
})
})
it('lets an advisor read a colleague advisor of a shared municipality only', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const colleaguePhone = fixtures.phone()
const foreignPhone = fixtures.phone()
const viewer = await fixtures.createCampaignUser('advisor', { phone: fixtures.phone() })
const colleague = await fixtures.createCampaignUser('advisor', { phone: colleaguePhone })
const foreignAdvisor = await fixtures.createCampaignUser('advisor', {
phone: foreignPhone,
})
const shared = await fixtures.getMunicipality()
const foreignMunicipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(shared, [viewer, colleague])
await fixtures.assignMunicipalityAdvisors(foreignMunicipality, [foreignAdvisor])
const visibleColleague = await payload.findByID({
collection: 'campaignUser',
id: colleague.id,
depth: 0,
select: { name: true, phone: true },
user: viewer,
overrideAccess: false,
})
const hiddenForeign = await payload.findByID({
collection: 'campaignUser',
id: foreignAdvisor.id,
depth: 0,
select: { name: true, phone: true },
user: viewer,
overrideAccess: false,
})
expect(visibleColleague.phone).toBe(colleaguePhone)
expect(hiddenForeign.phone).toBeUndefined()
})
})
it('does not leak foreign phones through direct list API reads', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisorPhone = fixtures.phone()
const foreignPhone = fixtures.phone()
const advisor = await fixtures.createCampaignUser('advisor', {
phone: advisorPhone,
})
const foreignAdvisor = await fixtures.createCampaignUser('advisor', {
phone: foreignPhone,
})
const leader = await fixtures.createCampaignUser('leader', {
phone: fixtures.phone(),
})
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
await createEngagedPhoneAccessGraph(fixtures, coordinator, leader, municipality.id)
const result = await payload.find({
collection: 'campaignUser',
where: { id: { in: [advisor.id, foreignAdvisor.id] } },
depth: 0,
pagination: false,
select: { name: true, phone: true },
user: leader,
overrideAccess: false,
})
const byId = new Map(result.docs.map((doc) => [doc.id, doc]))
// Leaders are lockdown accounts: no staff phone is visible, linked
// municipality or not.
expect(byId.get(advisor.id)?.phone).toBeUndefined()
expect(byId.get(foreignAdvisor.id)?.phone).toBeUndefined()
expect(JSON.stringify(result.docs)).not.toContain(advisorPhone)
expect(JSON.stringify(result.docs)).not.toContain(foreignPhone)
})
})
it('allows the record owner, coordinator, and Payload admin to read contact phone', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const phone = fixtures.phone()
const owner = await fixtures.createCampaignUser('leader', { phone })
const coordinator = await fixtures.createCampaignUser('coordinator')
const admin = await fixtures.createAdminUser({
email: `${fixtures.value('phone-admin')}@example.com`,
password: fixtures.value('password'),
})
for (const viewer of [owner, coordinator, admin]) {
const visible = await payload.findByID({
collection: 'campaignUser',
id: owner.id,
depth: 0,
select: { name: true, phone: true },
user: viewer,
overrideAccess: false,
})
expect(visible.phone).toBe(phone)
}
})
})
it('lets advisor read contact phones of coordinator users', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const coordinatorPhone = fixtures.phone()
const coordinator = await fixtures.createCampaignUser('coordinator', {
phone: coordinatorPhone,
})
const advisor = await fixtures.createCampaignUser('advisor', {
phone: fixtures.phone(),
})
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const visible = await payload.findByID({
collection: 'campaignUser',
id: coordinator.id,
depth: 0,
select: { name: true, phone: true },
user: advisor,
overrideAccess: false,
})
expect(visible.phone).toBe(coordinatorPhone)
})
})
it('allows owner and coordinator field updates but protects create from ordinary users', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const owner = await fixtures.createCampaignUser('leader', {
phone: fixtures.phone(),
})
const coordinator = await fixtures.createCampaignUser('coordinator')
const field = phoneField()
if (field.type !== 'text') throw new Error('Campo phone deve ser text.')
const ownerReq = stub<PayloadRequest>({
context: {},
payload,
user: owner,
})
const coordinatorReq = stub<PayloadRequest>({
context: {},
payload,
user: coordinator,
})
await expect(
field.access?.update?.(stub<FieldAccessArgs>({ id: owner.id, req: ownerReq })),
).resolves.toBe(true)
await expect(
field.access?.update?.(stub<FieldAccessArgs>({ id: owner.id, req: coordinatorReq })),
).resolves.toBe(true)
await expect(field.access?.create?.(stub<FieldAccessArgs>({ req: ownerReq }))).resolves.toBe(
false,
)
await expect(
field.access?.create?.(stub<FieldAccessArgs>({ req: coordinatorReq })),
).resolves.toBe(true)
})
})
})