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.
14 KiB
title, audience, summary
| title | audience | summary | |
|---|---|---|---|
| Testing |
|
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
# 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 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.yamlbrings up Postgres, Valkey, Keycloak, Temporal, and FedWiki, plus one-shot seeders (realm users, FedWiki identity, the Temporal default namespace). - Isolates each worktree.
./bootstrap-stack.shderives collision-free host ports and writestest/.env(COMPOSE_PROJECT_NAME, port assignments, and theMC_*config overrides), so several worktrees can run their own stacks concurrently without colliding. - Is where
TEST_DATABASE_URLcomes from. After bootstrap,test/.envdefinesMC_DB_DSN(Postgres on this worktree's port); integration tests point at it viaTEST_DATABASE_URL="$MC_DB_DSN". - Houses the end-to-end Go suites under
test/e2e/:plan-management/(in-processhttptestover the real handlers + a migrated DB) andoperator-walkthroughs/(Go tests that drive a browser against the running stack viaMC_BASE_URL;WALKTHROUGH_HEADFUL=1to watch). Both run undergo 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/), andteardown-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; 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/fulfillmentTestClassifyStatus,TestUnixToNullTime;internal/workflows/stripeTestCheckoutSessionPayloadParsing,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 driverlessgo 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 — butcoremust come first, since integration schemas' FKs point atcoreand can't apply before it exists. Seedocs/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}implementsstripe.Backend;stripetest.Install(m)swaps the global backend + a dummy key and returns a restore func (t.Cleanup(stripetest.Install(m))). - Cover:
fulfillment.ReconcileSubscriptionscenarios (empty-payload provisioning, idempotent replay, ordering, cancellation, multi-ladder guard) and the webhook→plan-transitionsend-to-end tests. - Gotcha:
Installmutates global stripe-go state, so these tests must not callt.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 bypassesSafeTemplatesby calling.ExecuteTemplate(w, …)directly (CSP/render discipline; seeinternal/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-processhttptestharness over the real handlers + a migrated DB (TEST_DATABASE_URL). Runs undergo 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 (readsMC_BASE_URLfromtest/.env; setWALKTHROUGH_HEADFUL=1to 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-devtoolsMCP (browser on127.0.0.1:9222viachrome-debug &), the Stripe MCP, andstripe listenwebhook 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 ofgo test.
Setup for all three lives in 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:
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_URLandt.Skipwhen unset — keepgo test ./...green with no infrastructure. (This is the one convention; the legacyDB_DSN+postgres-driver variant has been retired.) - Match
cmd/migrate.gosource 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.MockBackendreturning 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.shwhen you want a clean slate. - A wildcard CNAME shadows challenge lookups — see (7). A domain-verification
probe reporting
mismatchagainst 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_typesinto the thousands untiltest/e2e/operator-walkthroughstimes out rendering them. Check row counts before believing a walkthrough regression.
Related docs
test/AGENTS.md— the test stack (ports, webhook forwarding, demo seed).docs/database-management.md— migrations & sqlc.docs/stripe.md— the member-console ↔ Stripe responsibility split.