Root cause of the prod-build React #130 on /campanha/municipios: the md:hidden mobile-cards tree still hydrated ~25 rows x ~5 sheet controls (~125 eager Drawer/DrawerCloseButton instances) even on wide viewports. Filtered ?q=... routes failed more often only because they exercise the list with filters active — the search param itself was not the bug. CampaignListSheetProvider hosts a single bottom Drawer for the whole mobile section; CampaignCellEditOverlay sheet cells portal their body/footer into it while keeping per-cell open state and autosave scaffolds. Footers with DrawerCloseButton are only created once a sheet actually opens. Desktop popover path is unchanged; per-cell Drawer remains the fallback for surfaces without the provider. Also: DrawerCloseButton (function render prop, avoids base-ui element render in prod bundles) replaces the DrawerClose render={<Button/>} pattern across call sites; stale e2e selectors refreshed; e2e affected script + playwright github/list reporters for CI; use-mobile reverted (no consumers of the gate remain). Guardrail filed as issue #52.
12 KiB
Testing
Test Strategy
Three layers, all local-database-only (see local-database skill; tests refuse any database not ending in _test):
- Unit (
tests/unit/**/*.unit.spec.ts, 47 specs as of 2026-07-25 (tooling guards)): pure logic; runs with a deliberately invalidDATABASE_URLso nothing can touch a database. - 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-pr.yml) runs parallel jobs — lint (zero warnings), format, typecheck, knip, cycles, unit/int (affected by the PR diff: vitest --changed against the PR base via scripts/test-affected.mjs, full-suite fallback on high-risk paths), e2e (manifest path→spec via scripts/e2e-affected.mjs + scripts/lib/e2e-affected-manifest.mjs, blocking; production build + list/github reporters in CI) and build — against a Postgres 17 service with db:seed:minimal. Push to main (ci.yml) always runs the full net including e2e.
Local e2e before push (same blast radius as PR CI): pnpm test:e2e:affected — runs e2e-affected.mjs against origin/stage, migrates/seeds, then pnpm test:e2e with the selected specs (or the full suite on high-risk diffs). Forward extra Playwright args after -- for a narrowed repro. Opt into production mode (after fixing the known municipalities-list prod client bug) with E2E_PROD=1 pnpm test:e2e:affected.
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), an idempotent upsert of the rows the app refuses to run without:
- the four fail-closed
Consentkeys (lideranca-autopreenchimento,apoiador-cadastro,apoiador-intencao-voto,whatsapp-inscricao); - one
campaignUserper role (synthetic@teqo.invalidemails /719999999Xxphones); - 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, 1stateDeputy, 2leadership, 3supporter(all synthetic); - the
campaignGoalsglobal default.
The content contract is data in 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
Modules the 2026-07 hardening phases touch, and where their behavior is pinned:
| Module | Pinned behaviors | Test files | Gaps |
|---|---|---|---|
RBAC — src/utilities/access/* (split 2026-07-23; campaignAccess.ts is the re-export surface) |
per-role municipality/leadership/pledge/supporter/demand/plan scoping; leader lockdown; staff field asymmetry | campaignMunicipalityAccess, campaignVotePledge, campaignSupporter, campaignUserPhone, campaignActionPlan, campaignDemandWorkflow, collectionAccessLockdown (int) |
no dedicated per-export suite; covered through collection behavior |
| Collection access lockdown | campaign JWT denied on users/PII/CMS writes; anonymous read of public content; admin unaffected |
collectionAccessLockdown.int.spec.ts |
REST-transport variant untested (Local API with overrideAccess: false is equivalent) |
municipalityMapData.ts |
bundle scoping by URL filters + advisor access; validVotesByYear incl. 2026 reuse of 2022; null on empty scope; hasZoneMunicipalities + per-zone map keys |
municipalityMapData.int.spec.ts |
|
votePledgeData.ts |
estimated[scenario] ?? declared aggregation; role scoping; leader denial |
campaignVotePledge.int.spec.ts |
|
| Supporter import (token/bulk) | preview→confirm flow, dedup by phone, consent fail-closed | campaignSupporter.int.spec.ts |
supporterImportBulk/supporterImportToken internals only via flow |
| Leader supporter create | linked-municipality scope; out-of-scope rejection; staff rejection | collectionAccessLockdown.int.spec.ts |
|
| Posts visibility/caching | isPostVisible fail-closed; cached listing |
(indirect via routes) | no dedicated posts spec — backlog |
| List URL contracts (Pass 2 W0, pinned 2026-07-25) | campaignListUrl canonicalize/clamp/redirect; supporter + actionPlan parse/serialize/href; 5 entity parsers |
campaignListUrl, supporterUi, actionPlanUi, campaignEntityListParsers (unit) |
municipality list URL pinned separately in municipalityUi.unit.spec.ts |
| Form-action ladders (Pass 2 W4d) | runCampaignFormAction: success spread, boundary/zod/safe/generic mapping, values+revision echo |
campaignFormActions.unit.spec.ts |
per-route ladders are thin shells; behavior pinned at the wrapper |
| WhatsApp subscription consent (Pass 2 D3) | fail-closed without whatsapp-inscricao; happy path links subscription → keyed consent |
submitWhatsapp.int.spec.ts |
|
| Vote trend + election number format (Pass 2 W4a survivors) | computeVoteTrend pair priority/±10% band; formatElectionNumber E18 fraction defect |
voteTrend.unit.spec.ts |
|
| Entity list loaders (Pass 2 W0) | scope (leader empty/denied), marker-narrowed q, kind/status filters, pagination shape | campaignEntityListData.int.spec.ts |
advisor loader runs post-gate with overrideAccess: true (gate asserted upstream) |
| Codebase conventions (tooling guards, 2026-07-25) | server-only on Payload-coupled utilities; Praça/Núcleo copy ban; eslint legacy-ignore existence; formActions wrapper usage |
codebaseConventions.unit.spec.ts |
boundary rules themselves live in ESLint (no-restricted-imports) + madge in CI |
Characterization Backlog
src/utilities/posts.ts— dedicated spec forisPostVisiblefail-closed matrix and cache wrappers (confirmed 2026-07-28, Pass 3 audit).activityViewModels.ts(ex-actionPlanViewModels, renamed by C13) —activityUi.tspinned 2026-07-25 (Pass 2 W0); view models still untested directly (confirmed 2026-07-28, Pass 3 audit).campaignDemandData.ts/leadershipData.ts/organizationData.ts/stateDeputyData.tslist loaders — smoked 2026-07-25 (campaignEntityListData.int.spec.ts, Pass 2 W0).- Election aggregate artifact (Phase 2): snapshot test pinning artifact ↔
municipalityCatalogkey consistency — delivered asbahiaElectionAggregates.unit.spec.ts.