Skip to content

Testing

The repo has 1,477 unit and component tests across 108 files, plus 18 SQL suites and the Playwright e2e run. They run in CI on every PR and are expected to be green before a branch merges to main.

Test tooling

  • Unit / integration: Vitest with happy-dom. Config implicit from vite.config.ts + vitest dev dependency.
  • React Testing Library + @testing-library/user-event for component interaction tests.
  • MSW (msw) for mocking HTTP at the network layer (used sparingly — the Supabase mock is the common path).
  • End-to-end: Playwright (Chromium only in CI). Separate job in .github/workflows/ci.yml.
  • Typecheck: npm run typecheck (full tsc for tsconfig.app.json + tsconfig.node.json) and npm run typecheck:ratchet (what CI runs — fails only on errors not in scripts/typecheck-baseline.txt). Bare npx tsc --noEmit checks nothing: the root tsconfig.json is a solution file with "files": [].
  • Database behaviour tests: plain psql scripts in supabase/tests/*.sql, run by scripts/db-test.sh against a local Supabase. See Database tests.
  • Edge functions: scripts/deno-check-functions.sh [name …] type-checks supabase/functions/*/index.ts with Deno.
  • Migration guard: node scripts/check-migrations.mjs (also runs as pretest).

Running tests

# run the whole suite once (what CI runs)
npm run test:run

# watch mode — re-runs on file changes
npm test

# coverage report
npm run test:coverage

# typecheck (CI: ratchet against scripts/typecheck-baseline.txt)
npm run typecheck:ratchet
npm run typecheck:ratchet -- --update   # after fixing baseline errors
npm run typecheck                       # raw tsc output, all errors

# migrations: duplicate versions / bad names (error), unguarded CREATEs (warn)
node scripts/check-migrations.mjs            # --verbose lists grandfathered warnings too

# database behaviour tests (needs local Supabase — see below)
scripts/db-test.sh

# edge functions
scripts/deno-check-functions.sh              # all, or: scripts/deno-check-functions.sh mailer push-send

# playwright
npm run e2e
npm run e2e:ui      # interactive mode
npm run e2e:report  # view the last HTML report

Running a single test

Vitest accepts a filename substring and a -t test-name filter:

# run all tests in one file
npm test src/pages/Bookings/Bookings.test.tsx

# run only matching test names
npm test -- -t "rejects double-approve"

# both
npm test src/lib/api.test.ts -t "finance-approve"

For Playwright:

npx playwright test tests/e2e/booking-flow.spec.ts
npx playwright test -g "customer can pay via wallet"

Where tests live

Tests are colocated next to the code they exercise:

  • src/pages/<Feature>/<Feature>.test.tsx — page-level tests
  • src/services/<service>.test.ts — service-layer tests
  • src/lib/api.test.ts (+ topic-specific siblings) — backend handler tests
  • src/components/**/<Component>.test.tsx — component tests
  • supabase/functions/<function>/index.test.ts — Edge Function tests (Deno tests, run under Vitest via a shim or directly — )

Shared test infrastructure lives under src/test/:

File Purpose
src/test/harness/mockSupabase.ts In-memory Supabase client used by financial invariant tests
src/test/permissions.matrix.test.ts Drift detection between docs/PERMISSIONS.md and code references
(other harness files in src/test/harness/) Fixtures, helpers shared across test files

The Supabase mock

src/test/harness/mockSupabase.ts provides createMockSupabase() returning { client, capture, seed, reset }. It lets api.ts handlers run end to end without a real Postgres — so tests can assert on the actual journal entries, ledger rows, and invoice rows the handler emits.

Key idea: real business logic runs, only the data layer is mocked. Use it whenever you are testing a money-touching handler (finance approvals, wallet credits, voucher flows).

import { createMockSupabase } from '@/test/harness/mockSupabase';

const { client, capture, seed } = createMockSupabase();
seed('bookings', [{ id: 'bk_1', status: 'pending', total: 10000 }]);

// ... invoke the handler with `client` as its supabase ...

expect(capture.inserts('journal_entries')).toHaveLength(1);
expect(capture.inserts('journal_entries')[0]).toMatchObject({
  debit_account: 'cash', amount: 10000,
});

Warning

The mock only implements the Supabase methods api.ts actually calls. If you write a handler that uses a new builder method (e.g. .textSearch()), you will have to extend the mock before the test can run. Keep additions minimal and documented inline.

Database tests

Rules enforced in Postgres (RLS, triggers, constraints, SECURITY DEFINER RPCs) are tested by supabase/tests/*.sql. Each file is a plain psql script that:

  • sets \set ON_ERROR_STOP 1 and wraps everything in BEGIN; … ROLLBACK; (nothing persists),
  • impersonates users with SET ROLE authenticated + set_config('request.jwt.claims', …),
  • raises EXCEPTION 'FAIL …' on a broken expectation and NOTICE 'PASS …' otherwise,
  • ends with \echo ALL <NAME> TESTS PASSED.

scripts/db-test.sh runs every file (or the files you pass) against $DB_URL and counts a file as passed only if psql exits 0 and the ALL … PASSED line is printed. CI's db-tests job does the same after supabase start whenever migrations, tests or config.toml change.

Run them against a database built from migrations only. Several suites assert over the whole table — "no archived block is on the utilisation board", "no active user is without MFA", "no voucher was self-approved" — so any business data present becomes part of the assertion. On a stack seeded by scripts/dev/seed-demo.mjs eight suites fail on the demo rows alone while all 23 pass on the same migrations unseeded. If you need the tests and the preview at once, reset, test, then seed.

Run locally (default ports)

supabase start                 # applies every migration to a fresh local DB (first run pulls images)
scripts/db-test.sh             # DB_URL defaults to postgresql://postgres:postgres@127.0.0.1:54322/postgres
scripts/db-test.sh supabase/tests/audit_trail.sql   # one file
VERBOSE=1 scripts/db-test.sh   # print every PASS line

After changing a migration, re-apply from scratch with supabase db reset before re-running the tests. Only the Postgres container is needed — to start faster:

supabase start -x gotrue,realtime,storage-api,imgproxy,kong,mailpit,postgrest,postgres-meta,studio,edge-runtime,logflare,vector,supavisor

Port-shift trick (another Supabase project already on 54321–54327)

The CLI names containers after project_id and binds the ports in config.toml, so a second local stack collides with the first. Run the tests from a throw-away copy with a different project id and shifted ports instead of stopping the other project:

SIM=$(mktemp -d)/alhuda-dbtest
mkdir -p "$SIM/supabase"
cp -R supabase/migrations supabase/tests supabase/config.toml "$SIM/supabase/"
sed -i.bak -e 's/^project_id = .*/project_id = "alhuda-dbtest"/' \
           -e 's/= 5432\([0-9]\)/= 5532\1/' "$SIM/supabase/config.toml"   # 54321 → 55321, 54322 → 55322, …

(cd "$SIM" && supabase start -x gotrue,realtime,storage-api,imgproxy,kong,mailpit,postgrest,postgres-meta,studio,edge-runtime,logflare,vector,supavisor)
DB_URL=postgresql://postgres:postgres@127.0.0.1:55322/postgres scripts/db-test.sh

(cd "$SIM" && supabase stop --no-backup)       # remove containers + volume when done

Notes:

  • Services you don't exclude still bind their default ports (Studio 54323, Mailpit 54324, Analytics 54327, shadow DB 54320). Either exclude them as above or add explicit port = 553xx lines under [studio], [inbucket], [analytics] and shadow_port under [db].
  • To test against another Postgres major version, change major_version in the copy's config.toml (the repo config says 15; the tests pass on 15 and 17).
  • Re-sync the copy (cp -R supabase/migrations … again, then supabase db reset inside $SIM) after pulling new migrations.

access_model.sql — run this against restored databases too

supabase/tests/access_model.sql is the only suite that asserts the seeded state rather than behaviour: every RoleType value has a Role row, every permission in PERMISSIONS.md §4 exists, the role bundles match §5, and the row counts have not collapsed. It exists because that state can be wrong without anything erroring — grants are seeded by migrations that join public."Role" by name, so a missing role row silently skips every grant for it.

Run it after any restore or fresh provision, not just in CI:

DB_URL="$TARGET_DB_URL" scripts/db-test.sh supabase/tests/access_model.sql

See backup-restore.md → Verifying the access model after a restore.

Note for fixture authors: every role now exists with a full permission bundle, so a test that needs a role with known-empty permissions must clear its grants (DELETE FROM "RolePermission" … inside the test's transaction) rather than assume the row is new. Several suites do this — see audit_trail.sql.

Writing a DB test

Copy an existing file (e.g. supabase/tests/audit_trail.sql). Name PASS/FAIL messages after the rule ID they prove (PASS ACC-020 maker cannot approve) and keep the final \echo ALL … PASSED line — without it the runner reports a failure.

The permissions matrix test

src/test/permissions.matrix.test.ts parses docs/PERMISSIONS.md for the canonical permission catalog and diffs it against every 'x.y' permission string referenced in code (primarily src/lib/api.ts and src/App.tsx).

It fails the build when:

  • Code uses a permission name that isn't declared in PERMISSIONS.md, or
  • PERMISSIONS.md declares a permission that no code ever references.

Allowed exceptions live in ALLOWED_DOC_ONLY inside the test file — usually aliases kept for RLS policy compatibility, or permissions that have been seeded but not yet wired into UI. Every entry there is tech debt; don't add more without a tracking reason in the comment.

Tip

When this test fails, the error message tells you exactly which permission drifted and where. Fix either by updating docs/PERMISSIONS.md §4 or by wiring the permission into <ProtectedRoute requiredPermissions=[...]>, <PermissionGate permission="...">, or await requirePermission('...').

Coverage expectations

From CLAUDE.md: when you add a booking / finance / partner flow, write tests in the corresponding src/pages/**/*.test.tsx or src/services/*.test.ts file.

Required at minimum:

  • Happy path — the flow succeeds with valid input.
  • Permission gate — a user without the required permission is rejected (404 on frontend, 403 on backend).
  • Idempotency — if the flow is money-touching (approvals, payments, voucher creation), a double-submit must not create duplicate rows. See the ops-approve / finance-approve handlers for the pattern to assert against.
  • Validation — zod schema rejections return structured errors, not 500s.

For financial handlers specifically: assert on the shape of the journal entries the handler emits via the mock Supabase capture. The ledger invariant matters more than the HTTP status code.

What to do when a test fails in CI but passes locally

  1. Check Node version — CI uses Node 24. Run node -v locally. Switch with nvm use 24 or fnm use 24.
  2. Delete local caches: rm -rf node_modules && npm ci.
  3. Check timezone / locale — tests that render dates can differ by TZ. Tests should use date-fns with UTC or explicit timezones; if you find a TZ-dependent assertion, fix it.
  4. Flaky test? Re-run the CI job. If it still fails intermittently, mark it with .todo and open an issue rather than papering over with retries.