From 6ac26404d2d86ec9240b5dc136f7a68fe38f08c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 04:43:01 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20db:seed:minimal=20=E2=80=94=20minimal?= =?UTF-8?q?=20synthetic=20campaign=20DB=20for=20agents/CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TESTING.md | 14 +- package.json | 1 + scripts/lib/seed-minimal-manifest.mjs | 148 ++++++++++++ scripts/seed-minimal.mjs | 240 ++++++++++++++++++++ tests/unit/seedMinimalManifest.unit.spec.ts | 86 +++++++ 5 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 scripts/lib/seed-minimal-manifest.mjs create mode 100644 scripts/seed-minimal.mjs create mode 100644 tests/unit/seedMinimalManifest.unit.spec.ts diff --git a/docs/TESTING.md b/docs/TESTING.md index 8164084d..24f80a1d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 diff --git a/package.json b/package.json index 37ed3413..9513aa4a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lib/seed-minimal-manifest.mjs b/scripts/lib/seed-minimal-manifest.mjs new file mode 100644 index 00000000..8be918c9 --- /dev/null +++ b/scripts/lib/seed-minimal-manifest.mjs @@ -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', + }, +] diff --git a/scripts/seed-minimal.mjs b/scripts/seed-minimal.mjs new file mode 100644 index 00000000..3c2172c0 --- /dev/null +++ b/scripts/seed-minimal.mjs @@ -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) diff --git a/tests/unit/seedMinimalManifest.unit.spec.ts b/tests/unit/seedMinimalManifest.unit.spec.ts new file mode 100644 index 00000000..8c53b8b8 --- /dev/null +++ b/tests/unit/seedMinimalManifest.unit.spec.ts @@ -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) + }) +})