feat: db:seed:minimal — minimal synthetic campaign DB for agents/CI

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-30 04:43:01 -03:00
parent 571872f1a7
commit 6ac26404d2
5 changed files with 488 additions and 1 deletions

View file

@ -8,7 +8,19 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse
- **Integration** (`tests/int/**/*.int.spec.ts`, 48 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
- **E2E** (`tests/e2e`, Playwright, 7 specs): smoke-level; most campaign behavior is pinned at the int layer.
CI (`.github/workflows/ci.yml`) runs lint (zero warnings), typecheck, knip, unit, migrate, int against a Postgres 17 service.
CI (`.github/workflows/ci-pr.yml`) runs lint (zero warnings), typecheck, knip, unit, migrate, **`db:seed:minimal`**, int and build against a Postgres 17 service.
## Minimal test database (`pnpm db:seed:minimal`)
The integration suite and any agent/Cloud environment boot from a **minimal synthetic database**: schema from `pnpm migrate` (which seeds the 435-município catalog) plus `pnpm db:seed:minimal` ([`scripts/seed-minimal.mjs`](../scripts/seed-minimal.mjs)), an idempotent upsert of the rows the app refuses to run without:
- the four fail-closed `Consent` keys (`lideranca-autopreenchimento`, `apoiador-cadastro`, `apoiador-intencao-voto`, `whatsapp-inscricao`);
- one `campaignUser` per role (synthetic `@teqo.invalid` emails / `719999999Xx` phones);
- deterministic strategy pins (`priority` + `expectedVotes`) on 5 municípios (`salvador-ze-1`, `camacari`, `feira-de-santana`, `vitoria-da-conquista`, `itabuna`) — rows themselves come from the migration-seeded catalog;
- 1 `organization`, 1 `stateDeputy`, 2 `leadership`, 3 `supporter` (all synthetic);
- the `campaignGoals` global default.
The content contract is data in [`scripts/lib/seed-minimal-manifest.mjs`](../scripts/lib/seed-minimal-manifest.mjs), pinned by `tests/unit/seedMinimalManifest.unit.spec.ts`. **Contract: a delivery that adds a migration, collection, Consent key or required boot data must update the manifest in the same PR** — PR CI runs migrate → seed → int, so a broken seed fails the gate. The seed refuses non-local `DATABASE_URL` (same `assertLocalDatabase` guard family as the other seeds); agents and Cloud environments never set `ALLOW_REMOTE_DB`.
## Safety Net Map

View file

@ -30,6 +30,7 @@
"db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs",
"db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs",
"db:seed:projecao": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-projecao.mjs",
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
"build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs",
"build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs",
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",

View file

@ -0,0 +1,148 @@
/**
* Canonical content manifest of `pnpm db:seed:minimal` (pure data — imported by
* the seed script AND by the unit pin `tests/unit/seedMinimalManifest.unit.spec.ts`,
* which is the guardrail against a schema/migration delivery that forgets to
* update the minimal DB contract in the same PR).
*
* Everything here is synthetic (`.invalid` emails, 719999999Xx phones) and
* idempotent: the seed upserts by stable keys (slug/email/username/phone/key).
* Municipalities are NOT created — migrations seed all 435 catalog rows; the
* seed only pins deterministic operational fields on a handful of them.
*/
export const MINIMAL_SEED_VERSION = 1
/** Consent keys that MUST exist after the seed (the four fail-closed keys). */
export const MINIMAL_CONSENT_KEYS = [
'lideranca-autopreenchimento',
'apoiador-cadastro',
'apoiador-intencao-voto',
'whatsapp-inscricao',
]
export const MINIMAL_CAMPAIGN_GOALS = { stateGoal: 150_000, margin: 10, baseYear: 2022 }
export const MINIMAL_CAMPAIGN_USERS = [
{
role: 'coordinator',
name: 'Seed Coordenador',
email: 'seed-coordenador@teqo.invalid',
password: 'teqo-minimal-seed-password',
},
{
role: 'advisor',
name: 'Seed Assessor',
email: 'seed-assessor@teqo.invalid',
password: 'teqo-minimal-seed-password',
},
{
role: 'candidate',
name: 'Seed Candidato',
email: 'seed-candidato@teqo.invalid',
password: 'teqo-minimal-seed-password',
},
{
role: 'leader',
name: 'Seed Liderança',
username: '71999999990',
phone: '71999999990',
password: 'teqo-minimal-seed-password',
},
]
/**
* Municipality slugs the seed pins deterministic strategy fields on. They must
* exist in the catalog (the unit test proves it). Salvador ZE 1 + Camaçari are
* required by name in the Onda 0 plan; the other three spread across TIs.
*/
export const MINIMAL_MUNICIPALITIES = [
{
slug: 'salvador-ze-1',
priority: 'alta',
expectedVotes: { pessimistic: 4000, central: 5000, optimistic: 6500 },
},
{
slug: 'camacari',
priority: 'alta',
expectedVotes: { pessimistic: 2500, central: 3200, optimistic: 4000 },
},
{
slug: 'feira-de-santana',
priority: 'normal',
expectedVotes: { pessimistic: 2000, central: 2600, optimistic: 3300 },
},
{
slug: 'vitoria-da-conquista',
priority: 'normal',
expectedVotes: { pessimistic: 2200, central: 2800, optimistic: 3600 },
},
{
slug: 'itabuna',
priority: 'normal',
expectedVotes: { pessimistic: 1200, central: 1600, optimistic: 2100 },
},
]
export const MINIMAL_ORGANIZATION = {
name: 'Seed Sindicato dos Trabalhadores',
slug: 'seed-sindicato-dos-trabalhadores',
kind: 'sindicato',
municipalitySlugs: ['camacari'],
}
export const MINIMAL_STATE_DEPUTY = {
name: 'Seed Deputada Estadual',
slug: 'seed-deputada-estadual',
party: 'PT',
}
/**
* Leaderships link a synthetic Contact (unique by phone) to municipalities.
* `consentKey` references one of MINIMAL_CONSENT_KEYS.
*/
export const MINIMAL_LEADERSHIPS = [
{
contactName: 'Seed Liderança Um',
contactPhone: '71999999991',
municipalitySlugs: ['salvador-ze-1'],
supportStatus: 'engajado',
exclusive: true,
consentKey: 'lideranca-autopreenchimento',
},
{
contactName: 'Seed Liderança Dois',
contactPhone: '71999999992',
municipalitySlugs: ['camacari', 'itabuna'],
supportStatus: 'a_abordar',
exclusive: false,
consentKey: 'lideranca-autopreenchimento',
},
]
export const MINIMAL_SUPPORTERS = [
{
contactName: 'Seed Apoiador Um',
contactPhone: '71999999993',
municipalitySlug: 'salvador-ze-1',
voteIntention: 'certo',
source: 'manual',
consentKey: 'apoiador-cadastro',
voteIntentionConsentKey: 'apoiador-intencao-voto',
},
{
contactName: 'Seed Apoiador Dois',
contactPhone: '71999999994',
municipalitySlug: 'camacari',
voteIntention: 'indeciso',
source: 'evento',
consentKey: 'apoiador-cadastro',
voteIntentionConsentKey: 'apoiador-intencao-voto',
},
{
contactName: 'Seed Apoiador Três',
contactPhone: '71999999995',
municipalitySlug: 'feira-de-santana',
source: 'lideranca',
consentKey: 'apoiador-cadastro',
},
]

240
scripts/seed-minimal.mjs Normal file
View file

@ -0,0 +1,240 @@
/**
* Minimal synthetic campaign database for agents, Cursor Cloud and PR CI.
*
* Populates ONLY the rows the app refuses to boot/test without — everything is
* synthetic (`.invalid` emails, 719999999Xx phones) and idempotent (upsert by
* key/slug/email/username/phone). Municipalities are NOT created: migrations
* seed the full 435-row catalog; this script only pins deterministic strategy
* fields on the five listed in the manifest.
*
* The content contract lives in `scripts/lib/seed-minimal-manifest.mjs` and is
* pinned by `tests/unit/seedMinimalManifest.unit.spec.ts`. CONTRACT: a delivery
* that adds a migration, collection, Consent key or required boot data must
* update the manifest in the same PR — PR CI runs migrate → seed:minimal →
* test:int, so a broken seed fails the gate.
*
* Safety: refuses non-local DATABASE_URL unless ALLOW_REMOTE_DB=true. Agents
* and Cloud environments must never set that override.
*
* Usage:
* pnpm migrate && pnpm db:seed:minimal
*/
import { getPayload } from 'payload'
import { loadCliEnv } from './lib/cli.mjs'
import { assertLocalDatabase } from './assert-local-database.mjs'
import {
MINIMAL_CAMPAIGN_GOALS,
MINIMAL_CAMPAIGN_USERS,
MINIMAL_CONSENT_KEYS,
MINIMAL_LEADERSHIPS,
MINIMAL_MUNICIPALITIES,
MINIMAL_ORGANIZATION,
MINIMAL_STATE_DEPUTY,
MINIMAL_SUPPORTERS,
} from './lib/seed-minimal-manifest.mjs'
loadCliEnv()
assertLocalDatabase(
'seed:minimal',
'This script upserts synthetic rows. It must never run against stage/prod.',
)
const config = (await import('../src/payload.config.ts')).default
const { provisionOnda0ConsentAndPrivacy } = await import('../src/utilities/onda0Provision.ts')
const { hashConsentContent } = await import('../src/utilities/consentContentHash.ts')
const { WHATSAPP_SUBSCRIPTION_CONSENT_KEY } = await import('../src/lib/campaignConsentKeys.ts')
const payload = await getPayload({ config })
const minimalLexical = (text) => ({
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [{ type: 'text', text, version: 1 }],
direction: null,
format: '',
indent: 0,
version: 1,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
})
const findOne = async (collection, where) => {
const result = await payload.find({ collection, where, depth: 0, limit: 1, pagination: false })
return result.docs[0]
}
const upsert = async (collection, where, data, label) => {
const existing = await findOne(collection, where)
if (existing) {
console.log(`[seed:minimal] ok (exists) ${label}`)
return existing
}
const created = await payload.create({ collection, data, depth: 0 })
console.log(`[seed:minimal] created ${label}`)
return created
}
const municipalityIdBySlug = async (slug) => {
const doc = await findOne('municipality', { slug: { equals: slug } })
if (!doc) {
throw new Error(`Municipality "${slug}" missing — run pnpm migrate before pnpm db:seed:minimal.`)
}
return doc.id
}
try {
// 1. Consent keys (fail-closed flows refuse to run without them). The Onda 0
// provision covers three of the four keys + the privacy-policy global;
// whatsapp-inscricao is upserted here with the same minimal text.
await provisionOnda0ConsentAndPrivacy(payload)
const whatsappText = minimalLexical(
'Autorizo o recebimento de mensagens da campanha via WhatsApp (texto sintético do seed mínimo).',
)
await upsert(
'consent',
{ key: { equals: WHATSAPP_SUBSCRIPTION_CONSENT_KEY } },
{ key: WHATSAPP_SUBSCRIPTION_CONSENT_KEY, text: whatsappText },
`consent ${WHATSAPP_SUBSCRIPTION_CONSENT_KEY}`,
)
const consentIdByKey = new Map()
for (const key of MINIMAL_CONSENT_KEYS) {
const doc = await findOne('consent', { key: { equals: key } })
if (!doc) throw new Error(`Consent "${key}" missing after provisioning.`)
consentIdByKey.set(key, doc)
}
// 2. Campaign users, one per role (synthetic credentials for test DBs only).
for (const user of MINIMAL_CAMPAIGN_USERS) {
await upsert(
'campaignUser',
user.email
? { email: { equals: user.email } }
: { username: { equals: user.username } },
{ ...user },
`campaignUser ${user.role} (${user.email ?? user.username})`,
)
}
// 3. Municipality strategy pins (rows come from the migration-seeded catalog).
for (const entry of MINIMAL_MUNICIPALITIES) {
const id = await municipalityIdBySlug(entry.slug)
await payload.update({
collection: 'municipality',
id,
data: { priority: entry.priority, expectedVotes: entry.expectedVotes },
depth: 0,
})
console.log(`[seed:minimal] pinned municipality ${entry.slug}`)
}
// 4. Organization + state deputy.
const organization = await upsert(
'organization',
{ slug: { equals: MINIMAL_ORGANIZATION.slug } },
{
name: MINIMAL_ORGANIZATION.name,
slug: MINIMAL_ORGANIZATION.slug,
kind: MINIMAL_ORGANIZATION.kind,
municipalities: await Promise.all(
MINIMAL_ORGANIZATION.municipalitySlugs.map(municipalityIdBySlug),
),
},
`organization ${MINIMAL_ORGANIZATION.slug}`,
)
const stateDeputy = await upsert(
'stateDeputy',
{ slug: { equals: MINIMAL_STATE_DEPUTY.slug } },
{ ...MINIMAL_STATE_DEPUTY },
`stateDeputy ${MINIMAL_STATE_DEPUTY.slug}`,
)
// 5. Leaderships (contact upserted by phone; leadership unique by contact).
for (const entry of MINIMAL_LEADERSHIPS) {
const contact = await upsert(
'contact',
{ phone: { equals: entry.contactPhone } },
{ name: entry.contactName, phone: entry.contactPhone, state: 'BA' },
`contact ${entry.contactPhone}`,
)
const consent = consentIdByKey.get(entry.consentKey)
await upsert(
'leadership',
{ contact: { equals: contact.id } },
{
contact: contact.id,
municipalities: await Promise.all(entry.municipalitySlugs.map(municipalityIdBySlug)),
organizations: [organization.id],
stateDeputies: [stateDeputy.id],
supportStatus: entry.supportStatus,
exclusive: entry.exclusive,
consent: consent.id,
consentContentHash: hashConsentContent(consent.text ?? null),
consentedAt: new Date().toISOString(),
},
`leadership ${entry.contactName}`,
)
}
// 6. Supporters (unique by contact+municipality).
for (const entry of MINIMAL_SUPPORTERS) {
const contact = await upsert(
'contact',
{ phone: { equals: entry.contactPhone } },
{ name: entry.contactName, phone: entry.contactPhone, state: 'BA' },
`contact ${entry.contactPhone}`,
)
const municipalityID = await municipalityIdBySlug(entry.municipalitySlug)
const consent = consentIdByKey.get(entry.consentKey)
const voteIntentionConsent = entry.voteIntentionConsentKey
? consentIdByKey.get(entry.voteIntentionConsentKey)
: undefined
await upsert(
'supporter',
{ and: [{ contact: { equals: contact.id } }, { municipality: { equals: municipalityID } }] },
{
contact: contact.id,
municipality: municipalityID,
source: entry.source,
...(entry.voteIntention ? { voteIntention: entry.voteIntention } : {}),
consent: consent.id,
consentContentHash: hashConsentContent(consent.text ?? null),
consentedAt: new Date().toISOString(),
...(voteIntentionConsent
? {
voteIntentionConsent: voteIntentionConsent.id,
voteIntentionConsentContentHash: hashConsentContent(
voteIntentionConsent.text ?? null,
),
voteIntentionConsentedAt: new Date().toISOString(),
}
: {}),
},
`supporter ${entry.contactName}`,
)
}
// 7. Campaign goals global (E8 defaults).
await payload.updateGlobal({
slug: 'campaignGoals',
data: { ...MINIMAL_CAMPAIGN_GOALS },
overrideAccess: true,
})
console.log('[seed:minimal] pinned campaignGoals global')
console.log('[seed:minimal] OK — minimal synthetic database ready.')
} catch (error) {
console.error(`\n[seed:minimal] ${error instanceof Error ? error.message : String(error)}\n`)
process.exit(1)
}
process.exit(0)

View file

@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
// @ts-expect-error — plain .mjs data module, intentionally untyped
import {
MINIMAL_CAMPAIGN_GOALS,
MINIMAL_CAMPAIGN_USERS,
MINIMAL_CONSENT_KEYS,
MINIMAL_LEADERSHIPS,
MINIMAL_MUNICIPALITIES,
MINIMAL_ORGANIZATION,
MINIMAL_STATE_DEPUTY,
MINIMAL_SUPPORTERS,
} from '../../scripts/lib/seed-minimal-manifest.mjs'
import {
CAMPAIGN_INVITE_CONSENT_KEY,
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_VOTE_INTENTION_CONSENT_KEY,
WHATSAPP_SUBSCRIPTION_CONSENT_KEY,
} from '@/lib/campaignConsentKeys'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
/**
* Guardrail for the "migration/schema without seed update" miss pattern: the
* minimal-DB contract is data, pinned here, so a delivery that changes a
* consent key, renames a municipality slug or drops a seeded role fails the
* fast unit gate instead of only the slower PR-CI integration job.
*/
describe('seed-minimal manifest', () => {
it('covers every fail-closed Consent key the app resolves at runtime', () => {
expect([...MINIMAL_CONSENT_KEYS].sort()).toEqual(
[
CAMPAIGN_INVITE_CONSENT_KEY,
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_VOTE_INTENTION_CONSENT_KEY,
WHATSAPP_SUBSCRIPTION_CONSENT_KEY,
].sort(),
)
})
it('seeds exactly one campaign user per role, with synthetic credentials only', () => {
const roles = MINIMAL_CAMPAIGN_USERS.map((user: { role: string }) => user.role).sort()
expect(roles).toEqual(['advisor', 'candidate', 'coordinator', 'leader'])
for (const user of MINIMAL_CAMPAIGN_USERS) {
if (user.email) expect(user.email).toMatch(/@teqo\.invalid$/)
if (user.username) expect(user.username).toMatch(/^719999999\d\d$/)
}
})
it('pins only slugs that exist in the municipality catalog', () => {
expect(MINIMAL_MUNICIPALITIES.length).toBeGreaterThanOrEqual(3)
expect(MINIMAL_MUNICIPALITIES.length).toBeLessThanOrEqual(5)
const slugs = MINIMAL_MUNICIPALITIES.map((entry: { slug: string }) => entry.slug)
expect(slugs).toContain('salvador-ze-1')
expect(slugs).toContain('camacari')
for (const slug of slugs) {
expect(getMunicipalityCatalogEntry(slug), `slug ${slug}`).toBeDefined()
}
for (const entry of MINIMAL_MUNICIPALITIES) {
const { pessimistic, central, optimistic } = entry.expectedVotes
expect(pessimistic).toBeLessThanOrEqual(central)
expect(central).toBeLessThanOrEqual(optimistic)
}
})
it('references only pinned municipality slugs from org/leadership/supporter entries', () => {
const pinned = new Set(MINIMAL_MUNICIPALITIES.map((entry: { slug: string }) => entry.slug))
for (const slug of MINIMAL_ORGANIZATION.municipalitySlugs) expect(pinned.has(slug)).toBe(true)
for (const leadership of MINIMAL_LEADERSHIPS) {
for (const slug of leadership.municipalitySlugs) expect(pinned.has(slug)).toBe(true)
expect(MINIMAL_CONSENT_KEYS).toContain(leadership.consentKey)
}
for (const supporter of MINIMAL_SUPPORTERS) {
expect(pinned.has(supporter.municipalitySlug)).toBe(true)
expect(MINIMAL_CONSENT_KEYS).toContain(supporter.consentKey)
if (supporter.voteIntentionConsentKey) {
expect(supporter.voteIntentionConsentKey).toBe(SUPPORTER_VOTE_INTENTION_CONSENT_KEY)
}
}
expect(MINIMAL_STATE_DEPUTY.slug).toMatch(/^seed-/)
})
it('keeps the E8 campaign goals default sane', () => {
expect(MINIMAL_CAMPAIGN_GOALS.stateGoal).toBeGreaterThan(0)
})
})