Files
member-console/docs/testing.md
Christian Galo 966a309bfd docs: add testing guide
Document the kinds of tests (pure unit, DB-gated integration, Stripe-mocked,
HTTP handler/render, route/template lint, live e2e, OpenSpec validation), what
each covers, how to run them, and the role of the test/ stack directory. Add a
Testing subsection to the README pointing at it.
2026-05-23 21:51:16 -05:00

11 KiB
Raw Permalink Blame History

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 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; 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

Migration-source order matters. Self-migrating tests must list migration sources in the same order as cmd/migrate.go's migrationSources(). assembleMigrations numbers migrations by source position, so a different order assigns different global versions and tries to re-apply already-applied migrations (relation … already 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/stripetestMockBackend{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 e2etest/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 walkthroughstest/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 (worktree stack contract; the "Stripe webhook forwarding" section for the live loop).

7. 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

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.