mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
- 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>
153 lines
5.3 KiB
TypeScript
153 lines
5.3 KiB
TypeScript
// @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)
|
|
})
|
|
})
|