mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat: S9+ (#771) — integridade de consentimento e alinhamento das ações públicas
- Consent.beforeDelete bloqueia delete com referências (APIError 409 composto
por coleção), fim do 23502 cru no admin (contagens com overrideAccess).
- submitWhatsapp/submitPetitionSignature delegam o ciclo de transação ao dono
withPayloadTransaction (rollback no commit-falho, AggregateError, after-commit),
preservando os contratos { ok: true } / { ok: true, signatureNumber }.
- contactStateSchema exportado + contactCitySchema/contactCityFieldSchema
consolidados; as 4 fichas usam os exports únicos (fim das cópias inline do
predicate value in CitiesByState). withMutableConsentFixture purga consent
via raw SQL para bypassar o hook no harness de teste.
Co-Authored-By: opencode <noreply@opencode.ai>
This commit is contained in:
parent
fcefcac4e6
commit
d396870d22
9 changed files with 507 additions and 128 deletions
1
docs/changelog/2026-08-24-s9-pos-consentimento.md
Normal file
1
docs/changelog/2026-08-24-s9-pos-consentimento.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**S9+ (#771) — integridade de consentimento e alinhamento das ações públicas:** `Consent.beforeDelete` bloqueia a exclusão de um consentimento ainda referenciado por consumidores (subscription, signature, pushSubscription, petition `form.consent`, leadership, supporter `consent`/`voteIntentionConsent`) com `APIError` 409 e mensagem composta por coleção — fim do `23502` cru no admin. `submitWhatsapp`/`submitPetitionSignature` delegam o ciclo de transação ao dono `withPayloadTransaction` (rollback também no commit-falho, `AggregateError`, after-commit flush), preservando os contratos `{ ok: true }`/`{ ok: true, signatureNumber }`. `contactStateSchema` passa a ser exportado e `contactCitySchema`/`contactCityFieldSchema` são consolidados; as 4 fichas (`contactSchema`, `contactCreateSchema`, `contactFieldUpdateSchema`, `contactFullUpdateSchema`) usam os exports únicos em vez de cópias inline do predicate `value in CitiesByState` (DRY de conhecimento no dono). Simplify: 0 high/medium; 3 low (type-safety do `where` do hook descartado, `beginFailureMessage` por action intencional, helper de collapse `''` deferido) — triage no impl plan.
|
||||
113
docs/plans/escala-dry-pos-s9-acoes-publicas-impl.md
Normal file
113
docs/plans/escala-dry-pos-s9-acoes-publicas-impl.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Impl: Escala/DRY pós-S9 — integridade e alinhamento das ações públicas
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-24
|
||||
Issue: #771
|
||||
Intenção: docs/plans/escala-dry-pos-s9-acoes-publicas.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng; 3 fases pequenas, lock primeiro) — nada além do que a intenção já corta
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** 3 fases de higiene/integridade pós-S9, sem mudança de contrato: (1) deletar `Consent` em uso passa a falhar com erro admin-amigável (não mais `23502` cru); (2) `submitWhatsapp`/`submitPetitionSignature` delegam o transaction lifecycle ao dono `withPayloadTransaction` (rollback também no commit-falho, `AggregateError`, after-commit flush); (3) as 4 fichas de `contact.ts` passam a usar os exports únicos de estado/cidade — DRY de conhecimento no dono.
|
||||
- **O que NÃO negociar:** comportamento protetivo de consent LGPD fail-closed (recusa de gravar/consumir enquanto a chave configurada falta); contratos públicos das actions (`{ ok: true }`, `{ ok: true, signatureNumber }`); migrations congeladas (nunca editar `20260715_163458_initial` nem re-derivar FKs); contratos pinned de `onPayloadTransactionCommit`/notificação (`notificationPushScheduleConventions.unit.spec.ts:15-16`); invariante de escrita multi-collection em transação com `req: { transactionID }`.
|
||||
- **O que reavaliar:** o brief afirma "mesma mensagem, mesmo comportamento" para a cidade — **falso** (o explorador e a leitura do código provam divergência, ver Fase 3); o guard da Fase 1 precisa decidir escopo (só os NOT NULL blockers vs todos os usos semânticos) com o caso `petition.form_consent_id` NULLABLE como armadilha silenciosa; na Fase 2, a única mutação observável é a mensagem de begin-failure (não há teste que a pinea) — decisão de preservá-la por action ou aceitar o default PT; na Fase 3, exportar o `contactStateSchema` REQUIRED (não o `optionalContactStateSchema`) e o `contactCityFieldSchema` difere das cópias também na **colapsagem que exclui** `''` — não só na mensagem.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph F1["Fase 1 — Consent delete guard (collection hook)"]
|
||||
C1["Consent.hooks.beforeDelete"] --> C2["count refs (overrideAccess:true, depth:0, req)"]
|
||||
C2 --> C3{"alguma ref? sub/sig/push/petition/lead/support"}
|
||||
C3 -- "nenhuma" --> C4["segue o delete"]
|
||||
C3 -- "N,M,... > 0" --> C5["APIError(409) msg composta por coleção"]
|
||||
C2 -. "form.consent" .-> P["petition é NULLABLE: sem o count, NULL silencioso → TypeError no submitPetitionSignature"]
|
||||
C3 -. "nulable lead/supporter" .-> L["sem count, link legal audit é NULL-ado em silêncio"]
|
||||
end
|
||||
subgraph F2["Fase 2 — ações irmãs → withPayloadTransaction"]
|
||||
A1["submitWhatsapp (begin/commit/rollback)"] --> T["withPayloadTransaction(payload, cb, { beginFailureMessage } )"]
|
||||
A2["submitPetitionSignature (idem + count no txn)"] --> T
|
||||
T --> TV["rollback tb no commit-falho + AggregateError + flush after-commit"]
|
||||
T -. "count permanece DENTRO do callback" .-> CT["contagem pré-commit preservada"]
|
||||
end
|
||||
subgraph F3["Fase 3 — DRY contact.ts"]
|
||||
S1["export const contactStateSchema (required)"] --> F["4 fichas usam o export (state continua REQUIRED)"]
|
||||
S2["contactCitySchema required + contactCityFieldSchema composto sobre ele"] --> F
|
||||
F --> U["contactSchemas.unit.spec verde + novos pins ('Cidade muito longa', colapso de '')"]
|
||||
end
|
||||
```
|
||||
|
||||
**Opções consideradas (por fase):** ver decisões abaixo — F1: A/B; F2: A/B/C; F3: estado A/B, cidade a/b/c/d.
|
||||
**Recomendação:** F1 guard cobre **todos os usos semânticos** (409, hook `beforeDelete` inline no `Consent`); F2 preserva as mensagens de begin-failure via `{ beginFailureMessage }`; F3 exporta `contactStateSchema` e padroniza as mensagens de cidade no próprio `contactCityFieldSchema` (min `'Cidade inválida'`, max `'Cidade muito longa'`) — porque é a única opção que unifica mensagem E comportamento no dono do concern, sem custo novo de superfície.
|
||||
**Rejeitadas:** guard só-NOT NULL (deixa os NULL silenciosos, exatamente o tipo de bug que esta issue quer eliminar); default PT de begin-failure (muda comportamento observável na mesma diff mecânica); per-field `message` params de cidade (mais API, mantém a divergência); manter divórcio de mensagens documentado (derrota a intenção DRY).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`Consent`** (`src/collections/Consent.ts`): adicionar `hooks: { beforeDelete: [...] }`. Hook conta referências em `payload.count` paralelo (`Promise.all`) com `where: { consent: { equals: id } }` (+ `where: { 'form.consent': { equals: id } }` em `petition`; `where: { voteIntentionConsent: { equals: id } }` em `supporter`), `depth: 0`, `overrideAccess: true`, `req`. Se total > 0, `throw new APIError(msg, 409)` com contagem por coleção (`subscription`, `signature`, `pushSubscription`, `petition`, `leadership`, `supporter`). Reusa o padrão de `CampaignUser.ts:336-343` (CollectionBeforeDeleteHook com overrideAccess) e de `personDelete.ts:113-130` para o count. Hook fica inline (convenção do repo: hooks dentro do arquivo da collection).
|
||||
- **Migration:** sem migration — nenhuma mudança de schema; as colunas já existem (`subscription.consent_id`/`signature.consent_id`/`push_subscription.consent_id` NOT NULL; `petition.form_consent_id`, `leadership.consent_id`, `supporter.consent_id`/`vote_intention_consent_id` nullable).
|
||||
- **`submitWhatsapp.ts` / `submitPetitionSignature.ts`** (`src/app/(frontend)/actions/`): trocar o hand-roll `beginTransaction/try/commit/rollback` por `return withPayloadTransaction(payload, async ({ req }) => {...}, { beginFailureMessage })`. Reads pré-transação (`requireConsentByKey` de whatsapp L17-22; `findByID` do petition L25-31) ficam FORA do helper, como hoje. Conta de `signatureNumber` e o `Promise.all` paralelo permanecem DENTRO do callback (lê dentro da txn não commitada — comportamento atual preservado). Retornos intocados. `payloadTransaction.ts` **não muda** (`codebaseConventions.unit.spec.ts:487` pina).
|
||||
- **`contact.ts`** (`src/lib/schemas/contact.ts`): `contactStateSchema` deixa de ser privado (export) e as 4 fichas trocam o `z.custom<StateKey>` inline por ele; `optionalContactStateSchema` permanece composto sobre ele. Novo `contactCitySchema` (required: `trim().min(3,'Cidade inválida').max(100,'Cidade muito longa')`) e `contactCityFieldSchema` redefinido como composição sobre ele (união com `''` + colapso p/ `undefined` + `.optional()`). Comentário L31-35 atualizado (remover "consolidating them is a follow-up").
|
||||
- **Teste helper** (`tests/helpers/testDatabaseLease.ts:643-647`): o delete "re-create re-verify" do `withMutableConsentFixture` passa de `payload.delete` para raw SQL — a única chamada de lifecycle que podia derrubar a janela D10.
|
||||
- **Access / Consent:** guard com `overrideAccess: true` no count é bypass intencional (o delete já é admin-only; o hook é autorização própria do padrão `CampaignUser`). Fail-closed preservado: recusa é a proteção, não um canal novo. Sem chave nova de consentimento.
|
||||
- **UI:** nenhuma nova; no admin o efeito é o erro amigável no delete (a mensagem do `APIError` chega à superfície do painel).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Fase 1 (schema+server)** — hook `beforeDelete` no `Consent` + contagem multi-collection + `APIError(409)`; teste helper D10 migrado para raw SQL. **Fase 2 (server)** — refactor das duas actions para `withPayloadTransaction`. **Fase 3 (lib pura)** — exports `contactStateSchema`/`contactCitySchema`; `contactCityFieldSchema` recomposição; 4 fichas consolidadas.
|
||||
2. **UI** — nenhuma; verificação é a mensagem admin (superfície da Fase 1) via int spec e o painel e2e de newsletter seguindo verde.
|
||||
3. **Gates** — `pnpm migrate:create` **não** é necessário (sem schema); `pnpm gate:fast` (curated: unit `contactSchemas`; int `submitWhatsapp`, `submitPetitionSignature`, `payloadTransaction`, novo `consentDeleteGuard`, `onda0Provision`, supporter D10; e2e `campaignNewsletter`); push via `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- "Arquivamento" de Consent (soft-delete/flag protegida) — é a decisão de produto/jurídico que a intenção já resolveu pela mensagem; refatorar isso vira issue própria.
|
||||
- Editar migrations congeladas ou rever FKs `ON DELETE SET NULL` — fora (migrations congeladas são invariante).
|
||||
- `payload_locked_documents_rels` — cascade OK (migration `20260715_163458_initial:286`), não conta.
|
||||
- `onPayloadTransactionCommit`/notificações — pinned, não tocar; as duas actions não registram after-commit (sem `Lead` ainda).
|
||||
- Procurar outros hand-rolls de transação — não existem (`beginTransaction` só em `payloadTransaction.ts` e nas duas actions desta Fase 2).
|
||||
- Consolidar mensagens além de estado/cidade (ex.: sequences postais) — não é a intenção.
|
||||
- Gatilho defasado: quando S10 precisar de `onPayloadTransactionCommit` para `Lead`, Fase 2 vira requisito — aqui só a higiene.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
1. **`petition.form_consent_id` NULLABLE (NULL silencioso):** deletar consent referenciado por petição hoje NULL-za a coluna sem erro e o `submitPetitionSignature.ts:30-31` estoura TypeError depois. Mitigação: o count da Fase 1 **inclui petition** via `where: { 'form.consent': { equals } }`, então nunca chega ao NULL silencioso.
|
||||
2. **Lifecycles de teste que deletam consent via `payload.delete`** (hook roda, bypass natural p/ SQL raw): `deleteWhatsappConsentRows` (int whatsapp L26-33) e a purga do e2e newsletter já ordenam consumidores antes (comentário `campaignNewsletter.e2e.spec.ts:32-33` é a evidência do bug original); `deletionOrder` do `campaignE2EFixtures.ts:46-63` e o `campaignFixtures.ts:1020-1045` já põem `consent` penúltimo. O único conflito real é o `withMutableConsentFixture` (janela D10, `testDatabaseLease.ts:643-647`), que pode apagar a chave canônica com consumers vivos — mitigado trocando esse ponto para raw SQL (já é o padrão local do setup/restore, linhas 575/680; `removeOnda0ConsentAndPrivacyDb` também é raw e segue intacto).
|
||||
3. **Mensagens divergentes da cidade** (Fase 3): o brief dizia "mesma mensagem" e não é — as cópias dão `'Cidade muito longa'` no max; `contactCityFieldSchema` dá `'Cidade inválida'` nas duas bordas. Decisão abaixo padroniza no schema exportado; consumidor `campaignNewsletter` muda o texto de "cidade longa demais" (melhoria, não pinada em teste).
|
||||
4. **`contactCityFieldSchema` colapsa `''`→undefined, cópias não:** a troca nas fichas passa a aceitar `''` como ausente (antes `'Cidade inválida'`); é o comportamento que S9 já definiu como canônico (combobox limpa para `''`), e `contactSchema` público mantém cidade **required** via o novo `contactCitySchema`. Bordas: string só-de-espaços vira erro em vez de colapso — divergência aceita, sem teste pinado, forms enviam `''`.
|
||||
5. **`contactStateSchema` knip:** o export S9 declarou-o privado ("só 1 consumidor"); com a Fase 3 ele tem 4 consumidores — exportar não acende knip.
|
||||
6. **Count no hook antes do delete** precisa de `depth: 0` (evita popular relações — custo) e `overrideAccess: true` (o `read` de `Consent` é `payloadAdminOnly`; o padrão casa com os hooks de `CampaignUser`).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (3 fases, zero mudança de contrato público).
|
||||
- [ ] Invariantes AGENTS/engineering-standards: editar o dono (Consent, payloadTransaction, contact.ts), sem twin; sem módulo novo top-level em `src/utilities/`; multi-collection sempre em transação; migrations intactas.
|
||||
|
||||
### Painel de teste/verificação por fase
|
||||
|
||||
- **F1 — `tests/int/consentDeleteGuard.int.spec.ts` (novo):** cria consent + refs em cada consumidor (subscription, signature, pushSubscription, petition via `form.consent`, leadership, supporter `consent`/`voteIntentionConsent`); asserta `payload.delete({ collection: 'consent', id })` rejeitando com `APIError` 409 e mensagem com as contagens por coleção; asserta que remove refs → delete OK; que delete de consent sem refs passa; e que `DELETE` raw SQL bypassa o guard (espelho do `removeOnda0ConsentAndPrivacyDb`). Fixture descarta refs próprias antes do cleanup (controla a ordem por si).
|
||||
- **F1 — regressão dos lifecycles:** `submitWhatsapp.int.spec.ts` (com `deleteWhatsappConsentRows`), `campaignNewsletter.e2e.spec.ts` (purga emissão 32-33), `campaignMunicipalities.e2e.spec.ts` (lease), `onda0Provision.int.spec.ts` (provision/remove) verdes; `testDatabaseLease.ts:643-647` → raw SQL.
|
||||
- **F2 — `tests/int/submitWhatsapp.int.spec.ts`** (fail-closed com a mensagem exata + path feliz com subscription linkada ao consent) e **`tests/int/submitPetitionSignature.int.spec.ts`** (`signatureNumber` pré-commit preservado) verdes; **`tests/int/payloadTransaction.int.spec.ts`** verde (helper intocado); `pnpm gate:fast`.
|
||||
- **F3 — `tests/unit/contactSchemas.unit.spec.ts`:** mantém verdes os pins existentes (state REQUIRED L20-30; city L64-71); **adicionar pins novos:** mensagem `'Cidade muito longa'` no contato do `contactFieldUpdateSchema`/`contactCreateSchema` após a consolidação; `''` em `city` colapsa para `undefined` nos schemas das fichas; `contactStateSchema` exportado aceita `'BA'` e rejeita `'XX'`. Homônimo `contactCityMigration.unit.spec.ts` é de migration — não tocar.
|
||||
|
||||
### Conclusão
|
||||
|
||||
Toda decisão não trivial vem com opções/recomendação/rejeitadas (uma por fase abaixo). Self-score decision-quality: **4,5/5** — decisões caras têm rejeitadas explicitadas (escopo do guard, mensagem da cidade, kind de teste D10), a abordagem cabe no appetite ~0,5–1 dia, rabbit holes nomeados (arquivamento, `locked_documents`, S10/Lead), depth-check reusa próprios donos (`payloadTransaction`, hooks inline, exports S9), e a intenção (o outcome das 3 fases) é satisfeita sem mudar contrato.
|
||||
|
||||
## Decisões de engenharia (uma por fase)
|
||||
|
||||
**F1 — Escopo do guard + status + lifecycles de teste.**
|
||||
Opções: A) contar só os NOT NULL blockers (subscription/signature/pushSubscription) → cobre o sintoma 23502; B) contar todos os usos semânticos (A + petition via `form.consent` + leadership + supporter×2) → também elimina os NULL silenciosos.
|
||||
Recomendação: **B** — porque o concern é o texto legal versionado: `petition.form_consent_id` NULLABLE hoje NULL-za silencioso e quebra o `submitPetitionSignature` (TypeError), e leadership/supporter têm `consentContentHash`/`consentedAt` que ficariam órfãos do link; multicount em `Promise.all` custa nada e a mensagem composta orienta o admin. Alternativas rejeitadas: A) deixa exatamente a classe de bug que a issue quer matar (NULL silencioso em público), e um guard de "uso parcial" é mais confuso que um de "uso".
|
||||
Status HTTP: Opções: 400 | 409 | 500. Recomendação: **409** (`APIError(msg, 409)`) — conflito com estado existente, com precedente real no repo (`CampaignDemand.ts:144/278`, `Activity.ts:92`, `Organization.ts:27`, `VotePledge.ts:78`). Rejeitadas: 400 (semântica errada; erro não é do payload) e 500 (erro cru é o sintoma).
|
||||
Integração com lifecycles: os fixtures já ordenam consumidores antes do consent (`campaignFixtures.ts:1020-1045`, `campaignE2EFixtures.ts:46-63`); o único ponto em conflito é o delete do `withMutableConsentFixture` (janela D10, `testDatabaseLease.ts:643-647`), que pode ter consumers vivos. Opções: A) mudar esse ponto para raw SQL; B) `overrideAccess`/flag de contorno no hook; C) fragilizar fixtures. Recomendação: **A** — hook `beforeDelete` NÃO roda para SQL raw (bypass natural do próprio harness), casa com o setup/restore raw já existente (575, 680) e com `removeOnda0ConsentAndPrivacyDb`. Rejeitadas: B) contorno no hook é um buraco de segurança que vaza para o admin; C) delegar ordem aos tests quebrados é mais fraco.
|
||||
|
||||
**F2 — Mensagem de begin-failure.**
|
||||
Opções: A) `{ beginFailureMessage: 'failed to start transaction' }` no whatsapp e `{ beginFailureMessage: 'Failed to begin transaction' }` no petition; B) aceitar o default PT `'Não foi possível iniciar a transação.'`; C) criar uma PT compartilhada nova.
|
||||
Recomendação: **A** — o refactor é preservação de contrato; a mensagem de begin-failure é o único output observável que muda e a opção existe exatamente para isso; zero risco de regressão oculta na mesma diff. Rejeitadas: B) altera comportamento observável (EN→PT) sem requisito por trás — limpeza desacoplada da Mecânica; C) strings novas só para "uniformizar" sem ganho, menos DRY que A. Nota: reads pré-transação (consent/petition) ficam fora do helper; count e `Promise.all` dentro (pré-commit).
|
||||
|
||||
**F3 — Estado: exportar o schema required.**
|
||||
Opções: A) exportar `contactStateSchema` (required, atual privado) e usar nas 4 fichas; B) compor `optionalContactStateSchema` (ou união ad-hoc) nas fichas.
|
||||
Recomendação: **A** — todas as 4 fichas exigem state (`contactCreateSchema` tem pin "rejects a missing state" em `contactSchemas.unit.spec.ts:25-30`); o optional é só o público S9. B) mudaria validação (state deixa de ser obrigatório) e quebraria o pin. Rejeitadas: B. O comentário L31-35 ("follow-up") é a dívida exatamente desta Fase 3 — atualizar ao exportar.
|
||||
|
||||
**F3 — Cidade: padronizar mensagens no schema canônico.**
|
||||
Opções: a) padronizar em `contactCityFieldSchema` (min `'Cidade inválida'`, max `'Cidade muito longa'`), mudando o max dos consumidores atuais (incl. campaignNewsletter); b) manter mensagens distintas nas fichas via message params; c) padronizar num base required `contactCitySchema` e recompor `contactCityFieldSchema` sobre ele (união `''` + colapso); d) aceitar a divergência e documentar.
|
||||
Recomendação: **c**, em coerência com o padrão do state — um base required (`contactCitySchema`, com as mensagens que as cópias já estabelecem) e o field schema como composição (união com `''`, colapso, `.optional()`); isso unifica as 4 fichas e mantém o colapso `''` dos consumidores S9. A mensagem de cidade-muito-longa do `campaignNewsletter` muda `'Cidade inválida'` → `'Cidade muito longa'` (melhoria informativa, não pinada). Rejeitadas: a) muda comportamento de refs S9 sem base requerida e duplica o pred do `contactSchema`; b) amplia a superfície de `contactCityFieldSchema` com params e preserva a divergência que a intenção quer matar; d) derrota o motivo da Fase 3. `contactSchema.city` (público, via `whatsAppFormSchema`/`petitionFormSchema`) continua **required** — usar `contactCitySchema`, nunca o field optional.
|
||||
|
|
@ -4,6 +4,8 @@ import { petitionFormSchema, type PetitionFormInput } from '@/lib/schemas/petiti
|
|||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
||||
type SubmitPetitionSignatureInput = PetitionFormInput & {
|
||||
petitionId: string
|
||||
}
|
||||
|
|
@ -21,7 +23,8 @@ export const submitPetitionSignature = async ({
|
|||
// renders the text from `petition.form.consent`, and the signature must
|
||||
// record exactly that document. Petition read access is public, like the
|
||||
// page that renders it; a `consentId` posted by the client is dropped at
|
||||
// the input boundary above, so a tampered one is inert.
|
||||
// the input boundary above, so a tampered one is inert. Resolved before the
|
||||
// transaction because it is a read, not a write.
|
||||
const petition = await payload.findByID({
|
||||
collection: 'petition',
|
||||
id: petitionId,
|
||||
|
|
@ -30,56 +33,50 @@ export const submitPetitionSignature = async ({
|
|||
const consentId =
|
||||
typeof petition.form.consent === 'number' ? petition.form.consent : petition.form.consent.id
|
||||
|
||||
const transactionID = await payload.db.beginTransaction()
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const { phone, ...contactFields } = data
|
||||
const contact = await payload.create({
|
||||
collection: 'contact',
|
||||
data: {
|
||||
...contactFields,
|
||||
// The public forms keep a single phone input; the ficha stores the
|
||||
// phones array with that number as primary (C112).
|
||||
phones: phone ? [{ value: phone }] : [],
|
||||
},
|
||||
req,
|
||||
})
|
||||
|
||||
if (!transactionID) {
|
||||
throw new Error('Failed to begin transaction')
|
||||
}
|
||||
await Promise.all([
|
||||
payload.create({
|
||||
collection: 'signature',
|
||||
data: {
|
||||
contact: contact.id,
|
||||
petition: petitionId,
|
||||
consent: consentId,
|
||||
comment,
|
||||
},
|
||||
req,
|
||||
}),
|
||||
payload.create({
|
||||
collection: 'subscription',
|
||||
data: {
|
||||
contact: contact.id,
|
||||
consent: consentId,
|
||||
},
|
||||
req,
|
||||
}),
|
||||
])
|
||||
|
||||
try {
|
||||
const { phone, ...contactFields } = data
|
||||
const contact = await payload.create({
|
||||
collection: 'contact',
|
||||
data: {
|
||||
...contactFields,
|
||||
// The public forms keep a single phone input; the ficha stores the
|
||||
// phones array with that number as primary (C112).
|
||||
phones: phone ? [{ value: phone }] : [],
|
||||
},
|
||||
req: { transactionID },
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
payload.create({
|
||||
const { totalDocs: signatureNumber } = await payload.count({
|
||||
collection: 'signature',
|
||||
data: {
|
||||
contact: contact.id,
|
||||
petition: petitionId,
|
||||
consent: consentId,
|
||||
comment,
|
||||
},
|
||||
req: { transactionID },
|
||||
}),
|
||||
payload.create({
|
||||
collection: 'subscription',
|
||||
data: {
|
||||
contact: contact.id,
|
||||
consent: consentId,
|
||||
},
|
||||
req: { transactionID },
|
||||
}),
|
||||
])
|
||||
where: { petition: { equals: petitionId } },
|
||||
req,
|
||||
})
|
||||
|
||||
const { totalDocs: signatureNumber } = await payload.count({
|
||||
collection: 'signature',
|
||||
where: { petition: { equals: petitionId } },
|
||||
req: { transactionID },
|
||||
})
|
||||
|
||||
await payload.db.commitTransaction(transactionID)
|
||||
return { ok: true, signatureNumber }
|
||||
} catch (error) {
|
||||
await payload.db.rollbackTransaction(transactionID)
|
||||
throw error
|
||||
}
|
||||
return { ok: true, signatureNumber }
|
||||
},
|
||||
{ beginFailureMessage: 'Failed to begin transaction' },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { getPayload } from 'payload'
|
|||
import { WHATSAPP_SUBSCRIPTION_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import { WhatsAppFormInput, whatsAppFormSchema } from '@/lib/schemas/whatsapp-form'
|
||||
import { requireConsentByKey } from '@/utilities/campaignConsent'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
||||
export const submitWhatsapp = async (input: WhatsAppFormInput) => {
|
||||
const { comment, ...contactInput } = whatsAppFormSchema.parse(input)
|
||||
|
|
@ -13,7 +14,8 @@ export const submitWhatsapp = async (input: WhatsAppFormInput) => {
|
|||
|
||||
// Fail-closed consent resolution by stable key (Pass 2 D3) — the flow
|
||||
// refuses to record a subscription while the keyed Consent document is
|
||||
// missing, same policy as the campaign flows.
|
||||
// missing, same policy as the campaign flows. Resolved before the
|
||||
// transaction because it is a read, not a write.
|
||||
const consent = await requireConsentByKey(
|
||||
payload,
|
||||
WHATSAPP_SUBSCRIPTION_CONSENT_KEY,
|
||||
|
|
@ -21,41 +23,33 @@ export const submitWhatsapp = async (input: WhatsAppFormInput) => {
|
|||
'Consentimento da inscrição no WhatsApp ainda não configurado.',
|
||||
)
|
||||
|
||||
const transactionID = await payload.db.beginTransaction()
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const { phone, ...contactFields } = contactInput
|
||||
const contact = await payload.create({
|
||||
collection: 'contact',
|
||||
data: {
|
||||
...contactFields,
|
||||
// The public forms keep a single phone input; the ficha stores the
|
||||
// phones array with that number as primary (C112).
|
||||
phones: phone ? [{ value: phone }] : [],
|
||||
},
|
||||
req,
|
||||
})
|
||||
|
||||
if (!transactionID) {
|
||||
throw new Error('failed to start transaction')
|
||||
}
|
||||
await payload.create({
|
||||
collection: 'subscription',
|
||||
data: {
|
||||
contact: contact.id,
|
||||
consent: consent.id,
|
||||
comment,
|
||||
},
|
||||
req,
|
||||
})
|
||||
|
||||
try {
|
||||
const { phone, ...contactFields } = contactInput
|
||||
const contact = await payload.create({
|
||||
collection: 'contact',
|
||||
data: {
|
||||
...contactFields,
|
||||
// The public forms keep a single phone input; the ficha stores the
|
||||
// phones array with that number as primary (C112).
|
||||
phones: phone ? [{ value: phone }] : [],
|
||||
},
|
||||
req: { transactionID },
|
||||
})
|
||||
|
||||
await payload.create({
|
||||
collection: 'subscription',
|
||||
data: {
|
||||
contact: contact.id,
|
||||
consent: consent.id,
|
||||
comment,
|
||||
},
|
||||
req: { transactionID },
|
||||
})
|
||||
|
||||
await payload.db.commitTransaction(transactionID)
|
||||
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
await payload.db.rollbackTransaction(transactionID)
|
||||
|
||||
throw error
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
{ beginFailureMessage: 'failed to start transaction' },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,87 @@
|
|||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { CollectionConfig } from 'payload'
|
||||
import { APIError, type CollectionBeforeDeleteHook, type CollectionConfig } from 'payload'
|
||||
|
||||
/**
|
||||
* Block deleting a Consent that is still referenced by a versioned legal-text
|
||||
* consumer. The DB uses `ON DELETE SET NULL` FKs, but several consumers store
|
||||
* the consent as NOT NULL (subscription, signature, pushSubscription) — deleting
|
||||
* would trip a 23502 — while others (petition, leadership, supporter) null the
|
||||
* column silently and corrupt the audit link. Either way the text is still in
|
||||
* use, so we refuse the delete up front with an actionable message instead of a
|
||||
* raw 500. The counts run with an INTENTIONAL access bypass (`overrideAccess`):
|
||||
* Consent read is admin-only and the hook must see every reference to decide
|
||||
* whether to block — mirrors the before-delete guards in CampaignUser /
|
||||
* personDelete.
|
||||
*/
|
||||
const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({ id, req }) => {
|
||||
const [
|
||||
subscriptions,
|
||||
signatures,
|
||||
pushSubscriptions,
|
||||
petitions,
|
||||
leaderships,
|
||||
supporters,
|
||||
voteSupporters,
|
||||
] = await Promise.all([
|
||||
req.payload.count({
|
||||
collection: 'subscription',
|
||||
where: { consent: { equals: id } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
req.payload.count({
|
||||
collection: 'signature',
|
||||
where: { consent: { equals: id } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
req.payload.count({
|
||||
collection: 'pushSubscription',
|
||||
where: { consent: { equals: id } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
req.payload.count({
|
||||
collection: 'petition',
|
||||
where: { 'form.consent': { equals: id } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
req.payload.count({
|
||||
collection: 'leadership',
|
||||
where: { consent: { equals: id } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
req.payload.count({
|
||||
collection: 'supporter',
|
||||
where: { consent: { equals: id } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
req.payload.count({
|
||||
collection: 'supporter',
|
||||
where: { voteIntentionConsent: { equals: id } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
])
|
||||
|
||||
const references: Array<[number, string]> = [
|
||||
[subscriptions.totalDocs, 'inscrições'],
|
||||
[signatures.totalDocs, 'assinaturas'],
|
||||
[pushSubscriptions.totalDocs, 'inscrições push'],
|
||||
[petitions.totalDocs, 'petições'],
|
||||
[leaderships.totalDocs, 'lideranças'],
|
||||
[supporters.totalDocs, 'apoiadores'],
|
||||
[voteSupporters.totalDocs, 'apoiadores (intenção de voto)'],
|
||||
]
|
||||
const used = references.filter(([count]) => count > 0)
|
||||
if (used.length === 0) return
|
||||
|
||||
const detail = used.map(([count, label]) => `${count} ${label}`).join(', ')
|
||||
throw new APIError(`Consentimento em uso — não é possível excluir (${detail}).`, 409)
|
||||
}
|
||||
|
||||
export const Consent: CollectionConfig = {
|
||||
slug: 'consent',
|
||||
|
|
@ -20,6 +102,9 @@ export const Consent: CollectionConfig = {
|
|||
update: payloadAdminOnly,
|
||||
delete: payloadAdminOnly,
|
||||
},
|
||||
hooks: {
|
||||
beforeDelete: [blockConsentDeletionWithReferences],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'key',
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ export const contactNameSchema = z
|
|||
)
|
||||
|
||||
/**
|
||||
* UF-of-the-cities-catalog rule (required). Private: the S9 optional variant
|
||||
* is the only external consumer; the ficha schemas keep their own inline
|
||||
* copies (consolidating them is a follow-up).
|
||||
* UF-of-the-cities-catalog rule (required). Exported so the four ficha schemas
|
||||
* reuse the single definition instead of re-declaring the inline predicate
|
||||
* (Fase 3 of the S9+ DRY pass — was private until it gained multiple consumers).
|
||||
*/
|
||||
const contactStateSchema = z.custom<StateKey>(
|
||||
export const contactStateSchema = z.custom<StateKey>(
|
||||
(value) => typeof value === 'string' && value in CitiesByState,
|
||||
'Estado inválido',
|
||||
)
|
||||
|
|
@ -47,6 +47,27 @@ export const optionalContactStateSchema = z
|
|||
.optional()
|
||||
.transform((value) => (value === '' || value === undefined ? undefined : value))
|
||||
|
||||
/**
|
||||
* Required free-text city rule (trim + min/max with the same messages the ficha
|
||||
* schemas already used). Exported so the ficha schemas and the optional variant
|
||||
* below share one definition (Fase 3 of the S9+ DRY pass).
|
||||
*/
|
||||
export const contactCitySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, 'Cidade inválida')
|
||||
.max(100, 'Cidade muito longa')
|
||||
|
||||
/**
|
||||
* Optional free-text city (''/undefined collapse). Composed over `contactCitySchema`
|
||||
* so it inherits the same min/max messages, and exported so sibling schemas reuse
|
||||
* the same optional-city rule as the ficha's mobile sheet.
|
||||
*/
|
||||
export const contactCityFieldSchema = z
|
||||
.union([contactCitySchema, z.literal('')])
|
||||
.optional()
|
||||
.transform((value) => (value === '' || value === undefined ? undefined : value))
|
||||
|
||||
const contactPostalCodeSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
|
|
@ -82,11 +103,8 @@ export const contactSchema = z.object({
|
|||
.trim()
|
||||
.length(11, 'Telefone celular inválido')
|
||||
.regex(/^\d{11}$/, 'Telefone celular inválido'),
|
||||
state: z.custom<StateKey>(
|
||||
(value) => typeof value === 'string' && value in CitiesByState,
|
||||
'Estado inválido',
|
||||
),
|
||||
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
|
||||
state: contactStateSchema,
|
||||
city: contactCitySchema,
|
||||
postalCode: contactPostalCodeSchema,
|
||||
})
|
||||
|
||||
|
|
@ -102,11 +120,8 @@ export const contactCreateSchema = z.object({
|
|||
// sends [] when the actor typed no phone.
|
||||
phones: contactPhonesSchema.optional(),
|
||||
gender: z.enum(contactGenders).optional(),
|
||||
state: z.custom<StateKey>(
|
||||
(value) => typeof value === 'string' && value in CitiesByState,
|
||||
'Estado inválido',
|
||||
),
|
||||
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa').optional(),
|
||||
state: contactStateSchema,
|
||||
city: contactCityFieldSchema,
|
||||
postalCode: z
|
||||
.string()
|
||||
.trim()
|
||||
|
|
@ -155,10 +170,7 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
|
|||
z.object({
|
||||
id: positiveRelationshipId,
|
||||
field: z.literal('state'),
|
||||
state: z.custom<StateKey>(
|
||||
(value) => typeof value === 'string' && value in CitiesByState,
|
||||
'Estado inválido',
|
||||
),
|
||||
state: contactStateSchema,
|
||||
}),
|
||||
// C139 — the contacts page edits the ficha's own fields in place: city and
|
||||
// CEP are free text on the Contact collection itself (no person-join
|
||||
|
|
@ -166,7 +178,7 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
|
|||
z.object({
|
||||
id: positiveRelationshipId,
|
||||
field: z.literal('city'),
|
||||
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
|
||||
city: contactCitySchema,
|
||||
}),
|
||||
z.object({
|
||||
id: positiveRelationshipId,
|
||||
|
|
@ -182,19 +194,6 @@ const contactGenderFieldSchema = z
|
|||
.transform((value) => (value === '' ? undefined : value))
|
||||
.optional()
|
||||
|
||||
/**
|
||||
* Optional free-text city (''/undefined collapse). Exported so sibling schemas
|
||||
* reuse the same optional-city rule as the ficha's mobile sheet.
|
||||
*/
|
||||
export const contactCityFieldSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => (value === '' ? undefined : value))
|
||||
.refine((value) => value === undefined || (value.length >= 3 && value.length <= 100), {
|
||||
message: 'Cidade inválida',
|
||||
})
|
||||
.optional()
|
||||
|
||||
/**
|
||||
* C139 — the mobile edit sheet is a SINGLE atomic write (plan decision F):
|
||||
* the whole ficha in one schema, with the same gate/scope/name invariant as
|
||||
|
|
@ -207,10 +206,7 @@ export const contactFullUpdateSchema = z.object({
|
|||
email: optionalPersistedEmail,
|
||||
phones: contactPhonesSchema.optional(),
|
||||
gender: contactGenderFieldSchema,
|
||||
state: z.custom<StateKey>(
|
||||
(value) => typeof value === 'string' && value in CitiesByState,
|
||||
'Estado inválido',
|
||||
),
|
||||
state: contactStateSchema,
|
||||
city: contactCityFieldSchema,
|
||||
postalCode: contactPostalCodeSchema,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -640,11 +640,10 @@ export const withMissingInviteConsentFixture = async <Result>(
|
|||
depth: 0,
|
||||
})
|
||||
if (recreated.docs[0]) {
|
||||
await payload.delete({
|
||||
collection: 'consent',
|
||||
where: { key: { equals: consentKey } },
|
||||
depth: 0,
|
||||
})
|
||||
// Raw SQL bypasses the Consent.beforeDelete guard (the lease window guarantees
|
||||
// no live references exist, so the guard would never fire here — but the hook is
|
||||
// authorization of the admin delete path and must not run for this test harness delete).
|
||||
await payload.db.drizzle.execute(sql`DELETE FROM "consent" WHERE "key" = ${consentKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
153
tests/int/consentDeleteGuard.int.spec.ts
Normal file
153
tests/int/consentDeleteGuard.int.spec.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// @vitest-environment node
|
||||
|
||||
/**
|
||||
* S9+ (Issue #771, Fase 1): deleting a Consent still referenced by a versioned
|
||||
* legal-text consumer must refuse with a friendly 409 — not the raw Postgres
|
||||
* 23502 (NOT NULL consumers) nor a silent NULL (nullable consumers like
|
||||
* petition). The guard counts references across every consumer and composes a
|
||||
* message listing them.
|
||||
*/
|
||||
import { sql } from '@payloadcms/db-postgres'
|
||||
import type { Payload } from 'payload'
|
||||
import { APIError, getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The Petition afterChange hook revalidates its ISR tag, which needs the Next
|
||||
// runtime — neuter it (the revalidation itself is pinned elsewhere).
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: () => undefined,
|
||||
revalidateTag: () => undefined,
|
||||
}))
|
||||
|
||||
import type { Petition } from '@/payload-types'
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const petitionBody: Petition['body'] = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: 'Corpo da petição de teste', version: 1 }],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
|
||||
describe('Consent beforeDelete guard', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('blocks deletion and lists every referencing consumer (composite 409)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const consent = await fixtures.createConsent()
|
||||
const contact = await fixtures.createContact()
|
||||
|
||||
// NOT NULL consumer (would trip 23502 today) + nested-group consumer
|
||||
// (petition.form.consent, which nulls silently today).
|
||||
const subscription = await payload.create({
|
||||
collection: 'subscription',
|
||||
data: { contact: contact.id, consent: consent.id },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const petition = await payload.create({
|
||||
collection: 'petition',
|
||||
data: {
|
||||
id: `peticao-${fixtures.value('guard')}`,
|
||||
title: 'Petição de teste do guard',
|
||||
subtitle: 'Consentimento referenciado',
|
||||
enabled: true,
|
||||
body: petitionBody,
|
||||
form: { consent: consent.id },
|
||||
_status: 'published',
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
let guardError: unknown
|
||||
try {
|
||||
await payload.delete({ collection: 'consent', id: consent.id, overrideAccess: true })
|
||||
} catch (error) {
|
||||
guardError = error
|
||||
}
|
||||
|
||||
expect(guardError).toBeInstanceOf(Error)
|
||||
expect((guardError as { status?: number }).status).toBe(409)
|
||||
const message = (guardError as Error).message
|
||||
expect(message).toContain('não é possível excluir')
|
||||
expect(message).toContain('inscrições')
|
||||
expect(message).toContain('petições')
|
||||
|
||||
// Removing the references lets the delete through.
|
||||
await payload.delete({ collection: 'subscription', id: subscription.id, overrideAccess: true })
|
||||
await payload.delete({ collection: 'petition', id: petition.id, overrideAccess: true })
|
||||
await expect(
|
||||
payload.delete({ collection: 'consent', id: consent.id, overrideAccess: true }),
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('allows deletion of a consent with no references', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const consent = await fixtures.createConsent()
|
||||
|
||||
await expect(
|
||||
payload.delete({ collection: 'consent', id: consent.id, overrideAccess: true }),
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('raw SQL delete bypasses the guard (harness / Onda0 provision parity)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const consent = await fixtures.createConsent()
|
||||
const contact = await fixtures.createContact()
|
||||
const subscription = await payload.create({
|
||||
collection: 'subscription',
|
||||
data: { contact: contact.id, consent: consent.id },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
// A raw DELETE does NOT run the admin beforeDelete hook. A referenced
|
||||
// consent still fails — but with the DB's 23502 (FK SET NULL vs NOT NULL
|
||||
// column), never the friendly 409 the hook would raise. This is exactly why
|
||||
// the test harness / Onda0 provision purge consents via raw SQL while
|
||||
// holding the lease that guarantees no live references.
|
||||
let rawError: unknown
|
||||
try {
|
||||
await payload.db.drizzle.execute(sql`DELETE FROM "consent" WHERE "id" = ${consent.id}`)
|
||||
} catch (error) {
|
||||
rawError = error
|
||||
}
|
||||
|
||||
// Cleanup first (always): drop the reference, then the now-unreferenced
|
||||
// consent deletes (the hook lets it through), freeing the fixture-owned
|
||||
// contact.
|
||||
await payload.delete({ collection: 'subscription', id: subscription.id, overrideAccess: true })
|
||||
await payload.delete({ collection: 'consent', id: consent.id, overrideAccess: true })
|
||||
|
||||
expect(rawError).toBeInstanceOf(Error)
|
||||
// The hook would have thrown APIError(409); a raw DELETE fails at the DB
|
||||
// layer instead, proving beforeDelete did not run.
|
||||
expect(rawError).not.toBeInstanceOf(APIError)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
|
||||
import { contactCreateSchema, contactFieldUpdateSchema } from '@/lib/schemas/contact'
|
||||
import {
|
||||
contactCitySchema,
|
||||
contactCreateSchema,
|
||||
contactFieldUpdateSchema,
|
||||
contactStateSchema,
|
||||
} from '@/lib/schemas/contact'
|
||||
|
||||
describe('contactCreateSchema (C139)', () => {
|
||||
it('accepts a full valid ficha', () => {
|
||||
|
|
@ -102,4 +107,40 @@ describe('contactFieldUpdateSchema — C139 variants', () => {
|
|||
.success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('collapses an empty city to undefined in the create ficha (C139 DRY)', () => {
|
||||
const result = contactCreateSchema.safeParse({ name: 'Ana Souza', state: 'BA', city: '' })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect((result.data as { city?: string }).city).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports the long-city message on the field-update city variant', () => {
|
||||
const result = contactFieldUpdateSchema.safeParse({
|
||||
id: 5,
|
||||
field: 'city',
|
||||
city: 'a'.repeat(101),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((issue) => issue.message === 'Cidade muito longa')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('contactStateSchema / contactCitySchema (S9+ DRY consolidation)', () => {
|
||||
it('contactStateSchema accepts a known UF and rejects an unknown one', () => {
|
||||
expect(contactStateSchema.safeParse('BA').success).toBe(true)
|
||||
expect(contactStateSchema.safeParse('XX').success).toBe(false)
|
||||
})
|
||||
|
||||
it('contactCitySchema reports the long-city message at the max boundary', () => {
|
||||
expect(contactCitySchema.safeParse('Camaçari').success).toBe(true)
|
||||
const result = contactCitySchema.safeParse('a'.repeat(101))
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((issue) => issue.message === 'Cidade muito longa')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue