--- title: "Testing" audience: [developer] summary: "The test taxonomy (unit, DB-gated integration, Stripe-mocked, HTTP render, lint, e2e, OpenSpec validation), how to run each, and conventions for new tests." --- # Testing guide How testing works in member-console: the kinds of tests, what each covers, how to run them, and the conventions to follow when writing new ones. ## TL;DR ```bash # Fast feedback — pure unit tests only (integration tests skip without a DB): go test ./... go vet ./... # Full suite — needs a migrated Postgres (use the test stack): cd test && ./bootstrap-stack.sh && docker compose up -d # one-time per worktree set -a && . test/.env && set +a TEST_DATABASE_URL="$MC_DB_DSN" go test ./... # from repo root # Route/template integrity + render discipline: go run . lint # 65 routes / URL refs resolve (cmd/lint.go) make lint-templates # no handler bypasses SafeTemplates ``` `go test ./...` is always safe to run: every database- or Stripe-backed test **skips itself** when `TEST_DATABASE_URL` is unset. ## The `test/` directory — the local stack Most Go test files sit next to the code they cover (`internal/.../_test.go`). `test/` is the exception, and serves two roles: it's the **integration/e2e environment** — a Dockerized stack of every backing service the app needs (Postgres, Valkey, Keycloak/OIDC, Temporal, FedWiki) that the database-gated tests (types 2–3) and the e2e suites (type 6) run against — **and** it houses the higher-level **end-to-end Go test suites** under `test/e2e/`. Its role: - **Provides the backing services.** `test/compose.yaml` brings up Postgres, Valkey, Keycloak, Temporal, and FedWiki, plus one-shot seeders (realm users, FedWiki identity, the Temporal default namespace). - **Isolates each worktree.** `./bootstrap-stack.sh` derives collision-free host ports and writes `test/.env` (`COMPOSE_PROJECT_NAME`, port assignments, and the `MC_*` config overrides), so several worktrees can run their own stacks concurrently without colliding. - **Is where `TEST_DATABASE_URL` comes from.** After bootstrap, `test/.env` defines `MC_DB_DSN` (Postgres on this worktree's port); integration tests point at it via `TEST_DATABASE_URL="$MC_DB_DSN"`. - **Houses the end-to-end Go suites** under `test/e2e/`: `plan-management/` (in-process `httptest` over the real handlers + a migrated DB) and `operator-walkthroughs/` (Go tests that drive a browser against the running stack via `MC_BASE_URL`; `WALKTHROUGH_HEADFUL=1` to watch). Both run under `go test` (see type 6). - **Holds the stack's run-only assets:** `mc-config.yaml`, `secrets/` (gitignored Stripe keys + webhook secret), the opt-in demo seeder (`./test/seed-demo.sh` + `seed/`), and `teardown-stack.sh` (compose down + removes `.env`, `secrets/`, `testdata/`). Don't edit `test/.env` by hand — re-run `bootstrap-stack.sh`. The full contract (idempotency, the account-global Stripe webhook constraint, teardown) lives in [`test/AGENTS.md`](../test/AGENTS.md); read it before running the stack. ## The kinds of tests ### 1. Pure unit tests — no external dependencies Plain Go tests of pure logic: parsing, classification, formatting, guard conditions. They run on every `go test` invocation, need nothing external, and are fast. - **Cover:** webhook payload parsing, status classification, value mapping, small helpers, input guards. - **Examples:** `internal/fulfillment` `TestClassifyStatus`, `TestUnixToNullTime`; `internal/workflows/stripe` `TestCheckoutSessionPayloadParsing`, `TestInvoicePayloadParsing`, `TestCardStringField_*`. - **Write one when:** the behavior is a function of its inputs with no DB/HTTP/ Stripe dependency. Prefer pushing logic into such functions so it can be tested this way. ### 2. Database-gated integration tests Run against a real, migrated Postgres. They exercise queries, constraints (e.g. the `pool_provision_ladders` GiST exclusion, `chk_pool_provisions_source`), transactions, and cross-schema flows that unit tests can't reach. - **Gated on `TEST_DATABASE_URL`** — skip when unset, so they never block a driverless `go test ./...`. Driver is **pgx** (`_ "github.com/jackc/pgx/v5/stdlib"`). - **Cover:** the entitlement graph, provisioning, fulfillment reconcile, webhook projection, materialization, operator queries. - **Examples:** `internal/provisioning` (`TestAutoProvision…`), `internal/fulfillment` (`TestReconcile_*`), `internal/entitlements/materialize_test.go`, `internal/workflows/stripe` (`*_Integration`, transition/product-catalog tests). Two sub-patterns for the `testDB(t)` helper — know which one you're following: | Sub-pattern | `testDB` behavior | Used by | |---|---|---| | **Self-migrating** | calls `db.RunMigrations(...)` to build the schema from scratch | `provisioning`, `fulfillment`, `entitlements/materialize`, `workflows/entitlements`, `server/operator_plan_ladders` | | **Assume-migrated** | just `sql.Open` against an already-migrated DB | `workflows/stripe` | > **Use the canonical source list.** Self-migrating tests should build their > sources via `migrate.Sources()` rather than hand-rolling a subset. Each > source migrates against its own goose ledger (`goose_db_version_`), > so source order doesn't affect version numbering — but `core` must come > first, since integration schemas' FKs point at `core` and can't apply > before it exists. See `docs/database-management.md`. **Fixtures:** `provisioning.AutoProvision(ctx, db, claims)` bootstraps a fresh org + default pool + billing account in one call — the cheapest way to get a clean tenant. Layer the plan graph (entitlement set → product → price → ladder → tier, plus stripe mappings) on top with the sqlc `Create*` helpers. Use unique suffixes (`uuid.New().String()[:8]`) so repeated runs against the shared stack DB don't collide; rows are left behind in the disposable test database. ### 3. Stripe-mocked tests A specialization of the integration tests: the Stripe SDK is pointed at an in-process fake so fulfillment's "refetch from the Stripe API" path runs deterministically and offline, with **no live Stripe account**. - **Helper:** `internal/stripetest` — `MockBackend{Sub, List, Err}` implements `stripe.Backend`; `stripetest.Install(m)` swaps the global backend + a dummy key and returns a restore func (`t.Cleanup(stripetest.Install(m))`). - **Cover:** `fulfillment.ReconcileSubscription` scenarios (empty-payload provisioning, idempotent replay, ordering, cancellation, multi-ladder guard) and the webhook→`plan-transitions` end-to-end tests. - **Gotcha:** `Install` mutates **global** stripe-go state, so these tests must **not** call `t.Parallel()`. ### 4. HTTP handler / render tests Exercise handlers through `net/http/httptest` plus the real `SafeTemplates` renderer, session manager (`scs`), and CSRF middleware. - **Cover:** rendered output, template wiring, auth guards (e.g. 401 for unauthenticated), CSRF behavior. - **Examples:** `internal/server/member_upgrade_render_test.go` (`TestMemberPlansUpgradeControlRendering`, `TestDashboardCheckoutBanner`, `TestGetPlansUnauthenticatedReturns401`); `internal/middleware/tests/decompress_test.go`. ### 5. Route & template lint (static) Not Go tests, but part of the green-bar definition: - **`go run . lint`** (`cmd/lint.go`) — operator-UI integrity: every registered route and templated URL reference resolves. Reports e.g. `lint: ok (65 routes, 29 URL refs)`. - **`make lint-templates`** — guards that no handler bypasses `SafeTemplates` by calling `.ExecuteTemplate(w, …)` directly (CSP/render discipline; see `internal/server/render.go`). ### 6. End-to-end tests Exercise whole flows against a running stack. Three flavors: - **Go HTTP-level e2e** — `test/e2e/plan-management/`: an in-process `httptest` harness over the real handlers + a migrated DB (`TEST_DATABASE_URL`). Runs under `go test`; covers operator plan/ladder/grant flows end-to-end at the HTTP layer. - **Go browser walkthroughs** — `test/e2e/operator-walkthroughs/`: Go tests that drive a real browser against the running stack (reads `MC_BASE_URL` from `test/.env`; set `WALKTHROUGH_HEADFUL=1` to watch). One walkthrough per operator route group, each asserting the four-bullet UI contract (empty/invalid → 422, valid → success + DOM update, …). Skips unless the stack is up and seeded. - **Manual / agent-driven live loop** — the `chrome-devtools` MCP (browser on `127.0.0.1:9222` via `chrome-debug &`), the Stripe MCP, and `stripe listen` webhook forwarding, for flows not yet codified — e.g. a real Stripe Checkout → `checkout.session.completed` → reconcile → the member UI reflecting the new plan. Not part of `go test`. Setup for all three lives in [`test/AGENTS.md`](../test/AGENTS.md) (worktree stack contract; the "Stripe webhook forwarding" section for the live loop). ### 7. Live DNS verification (domain claims, manual) Some of the domains registry can only be proven against a real zone: that a published challenge record activates a claim, and that publishing the challenge *prefix* with the wrong token latches `evidence_at` (exempting the claim from the abandonment ledger) without verifying. DB tests cover the branching; only real DNS covers the resolver path. You need a **disposable zone you control** — any domain whose records you can add and remove, at any registrar or DNS host with an API. Keep it separate from anything the deployment actually serves, and never commit credentials for it; supply them from the environment at the moment you use them. If the harness blocks outbound HTTP from the shell (the context-mode routing rules do), drive the DNS provider's API from a sandboxed script instead. The loop is: start a claim in the console, read its token, publish `_member-console-challenge.` as TXT, wait for a probe, assert, then delete the record. Two cases are worth proving because they diverge — a TXT carrying the challenge **prefix with a wrong token** must latch evidence *without* activating the claim, while the **correct token** must activate it. **The gotcha that will cost you ten minutes.** If the zone has a **wildcard record** (`*.example` CNAME/ALIAS/TXT), resolvers synthesise it for any challenge name that has no explicit record — so the probe reports "Found, but doesn't match" against whatever the wildcard points at, long before you did anything wrong. Publishing an explicit record stops the synthesis (RFC 4592 closest-encloser), but any cached wildcard answer still wins until its TTL expires, so probes keep reporting `mismatch` for minutes after DNS is already correct. Before concluding the prober is broken, compare authoritative against cached: ```bash dig +short @ TXT _member-console-challenge. # what the zone says dig +noall +answer TXT _member-console-challenge. # what the console sees ``` Clean up the records you added, and leave alone any challenge record that backs a claim you intend to keep verified. ### 8. OpenSpec validation (change workflow) `openspec validate ` is not a Go test but is part of the definition of done for a spec-driven change: it checks delta specs are well-formed (MODIFIED requirement headers match the main spec, every requirement has a `####` scenario, etc.). Run it before archiving a change. ## Choosing what to write ``` Is the behavior a pure function of inputs? → unit test (1) Does it touch the DB / constraints / multi-table? → DB integration test (2) Does it call the Stripe API? → add a stripetest mock (3) Is it an HTTP handler / rendered page? → httptest render test (4) Is it route/template wiring or render discipline? → go run . lint / lint-templates (5) Is it a whole user flow (HTTP or operator UI)? → e2e (6): a test/e2e/ Go suite Is it a flow only real infra can prove (live Stripe)? → the live MCP loop (6), see test/AGENTS.md Does it depend on what a resolver actually returns? → live DNS on a zone you control (7) ``` Prefer the cheapest test that genuinely covers the behavior. Reach for an e2e suite or the live loop (6) to *prove a flow works end-to-end*, then capture the regression-proofing parts as (2)/(3) so they run in CI without a live account. ## Conventions & gotchas - **Gate DB/Stripe tests on `TEST_DATABASE_URL`** and `t.Skip` when unset — keep `go test ./...` green with no infrastructure. (This is the one convention; the legacy `DB_DSN` + `postgres`-driver variant has been retired.) - **Match `cmd/migrate.go` source order** in self-migrating tests (see above). - **Don't trust webhook payloads in tests** the way production doesn't: a Stripe test should drive behavior through `stripetest.MockBackend` returning canned *API* state, not by hand-building the event payload's fields. - **No `t.Parallel()` in Stripe-mocked tests** — they share global SDK state. - **Unique fixture ids** for tests that write to the shared stack DB; tear the stack down with `test/teardown-stack.sh` when you want a clean slate. - **A wildcard CNAME shadows challenge lookups** — see (7). A domain-verification probe reporting `mismatch` against a record you just published is usually a cached wildcard, not a defect. - **Fixture pollution breaks the browser walkthroughs.** DB-gated suites seed rows they never clean up, so repeated full-suite runs against the shared stack can grow `core.plan_ladders` / `core.org_types` into the thousands until `test/e2e/operator-walkthroughs` times out rendering them. Check row counts before believing a walkthrough regression. ## Related docs - [`test/AGENTS.md`](../test/AGENTS.md) — the test stack (ports, webhook forwarding, demo seed). - [`docs/database-management.md`](database-management.md) — migrations & sqlc. - [`docs/stripe.md`](stripe.md) — the member-console ↔ Stripe responsibility split.