mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 14:55:16 +00:00
- scripts/check-push-chain.mjs: default --site, usage, erro → canônico; prosa Neon/vercel → homeserver - tests: fixtures unit (WebAuthn, JsonMutation, aiMarkdownLinks) + int (campaignMultiPhones) → canônico, semântica preservada - campaignMultiPhones: adiciona ensureLeasedConsent para invite consent fixture (fix pre-existente) - personas (.opencode/agent/designer, solla-comunicacao, skill): canônico + operação homeserver - docs: intenção + impl plan + changelog entry; CHANGELOG-AGENTS append-only preservado - Sem migration, sem schema, sem access/Consent, sem UI - Closes #796
466 lines
16 KiB
TypeScript
466 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 {
|
|
CAMPAIGN_INVITE_CONSENT_KEY,
|
|
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 {
|
|
CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
|
|
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://jorgesolla1313.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.
|
|
await ensureLeasedConsent(payload, {
|
|
consentKey: CAMPAIGN_INVITE_CONSENT_KEY,
|
|
leaseKey: CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
|
|
})
|
|
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)
|
|
})
|
|
})
|