teqo/tests/unit/campaignInviteArchitecture.unit.spec.ts
Francisco Solla 162a518416 feat(C111): telefone não único — duas pessoas podem compartilhar o mesmo celular
Contact.phone deixa de ser único em enforcement (nunca houve constraint DB):
- remove o hook enforceUniqueContactPhone e os asserts assertContactPhone*
  (conflicts/ambiguity) — os 5 fluxos de escrita de pessoa salvam com número
  compartilhado; rename contactPhoneInvariant.ts -> contactPhoneLocks.ts
  (locks passam a significar serialização, não unicidade)
- findOrCreateContactByPhone: 0 fichas cria, 1 reusa (dedupe C6/C99),
  2+ cria ficha nova (a identidade da pessoa em criação é a ficha nova)
- convite autofill/login: ficha ancorada pode compartilhar o phone; o lado
  conta permanece fail-closed (username DB-unique; check de 2+ contas)
- import CSV: preview sinaliza telefone_compartilhado (resolução manual);
  confirm/bulk abortam fail-closed se um phone ganhar 2+ fichas (race)
- fix de achado de execução: supporter_import_batch.actor_id NOT NULL com
  FK ON DELETE set null derrubava o delete de campaignUser com batch
  órfão (23502) — CampaignUser.beforeDelete limpa batches + fixture
  cleanup descobre/deleta batches dos accounts owned
2026-08-10 23:03:05 -03:00

94 lines
3.7 KiB
TypeScript

// @vitest-environment node
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { resolveInviteConsent } from '@/utilities/campaignConsent'
const source = (relativePath: string): string =>
readFileSync(resolve(process.cwd(), relativePath), 'utf8')
const lineCount = (contents: string): number => contents.split(/\r?\n/).length
describe('campaign invite module boundaries', () => {
const actionPath = 'src/app/(campaign)/campanha/actions/invite.ts'
const modules = {
repository: 'src/utilities/campaignInviteRepository.ts',
creation: 'src/utilities/campaignInviteCreation.ts',
redemption: 'src/utilities/campaignInviteRedemption.ts',
} as const
it('keeps privileged invite modules server-only and focused', () => {
const repository = source(modules.repository)
const creation = source(modules.creation)
const redemption = source(modules.redemption)
for (const contents of [repository, creation, redemption]) {
expect(contents).toMatch(/^import ['"]server-only['"]/)
}
expect(repository).toContain('acquireTextAdvisoryLocks')
expect(repository).toContain('hashCampaignInviteToken')
expect(repository).toContain('UPDATE "campaign_invite"')
expect(repository).not.toContain('generateCampaignInviteToken')
expect(repository).not.toContain('buildCampaignInviteWhatsAppLink')
expect(creation).toContain('reloadCampaignActor')
expect(creation).toContain('requireLeadershipConsent')
expect(creation).toContain('buildCampaignInviteWhatsAppLink')
expect(creation).not.toContain('@payloadcms/db-postgres')
expect(redemption).toContain('resolveInviteConsent')
// C111 — the invite may write a phone shared with another ficha, but the
// account side stays fail-closed: the username is the login key (DB-unique)
// and the "more than one account" guard must survive.
expect(redemption).toContain('contactPhoneLockKeys')
expect(redemption).toContain('Existe mais de uma conta para este celular.')
expect(redemption).toContain("role: 'leader'")
expect(redemption).not.toContain('@payloadcms/db-postgres')
})
it('keeps the use-server entrypoint thin with only public actions exported', () => {
const action = source(actionPath)
const exports = [...action.matchAll(/^export const (\w+)/gm)].map(([, name]) => name).sort()
expect(action).toMatch(/^['"]use server['"]/)
expect(exports).toEqual([
'createCampaignInvite',
'redeemCampaignInviteAutofill',
'redeemCampaignInviteLogin',
])
expect(action).not.toContain('@payloadcms/db-postgres')
expect(action).not.toContain('overrideAccess')
expect(action).not.toContain('hashCampaignInviteToken')
expect(action).not.toContain('password:')
expect(lineCount(action)).toBeLessThanOrEqual(75)
})
it('keeps invite modules within their responsibility budgets', () => {
expect(lineCount(source(modules.repository))).toBeLessThanOrEqual(160)
expect(lineCount(source(modules.creation))).toBeLessThanOrEqual(150)
expect(lineCount(source(modules.redemption))).toBeLessThanOrEqual(340)
})
it('preserves consent only for the exact configured version', () => {
expect(
resolveInviteConsent({
existingConsentID: 7,
existingConsentContentHash: 'stable-hash',
configuredConsentID: 7,
configuredConsentContentHash: 'stable-hash',
}),
).toEqual({ shouldUpdate: false })
expect(() =>
resolveInviteConsent({
existingConsentID: 7,
existingConsentContentHash: 'old-hash',
configuredConsentID: 7,
configuredConsentContentHash: 'new-hash',
}),
).toThrow('É necessário aceitar o consentimento.')
})
})