Files
member-console/docs/testing.md
T
cgalo5758 c4bb1ba585 Harden domain claim expiry and carving
Sweep stranded pending claims at boot and on a Temporal schedule while
preserving evidence-based abandonment semantics.

Apply occupancy and name-policy checks to carves by operator-root owners
without affecting direct operator placements.
2026-07-25 04:10:34 -05:00

264 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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/.../<name>_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 23) 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_<name>`),
> 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.<name>` 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 @<authoritative-ns> TXT _member-console-challenge.<name> # what the zone says
dig +noall +answer TXT _member-console-challenge.<name> # 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 <change>` 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.