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
94 lines
3.7 KiB
TypeScript
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.')
|
|
})
|
|
})
|