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 fromvite.config.ts+vitestdev dependency. - React Testing Library +
@testing-library/user-eventfor 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(fulltscfortsconfig.app.json+tsconfig.node.json) andnpm run typecheck:ratchet(what CI runs — fails only on errors not inscripts/typecheck-baseline.txt). Barenpx tsc --noEmitchecks nothing: the roottsconfig.jsonis a solution file with"files": []. - Database behaviour tests: plain
psqlscripts insupabase/tests/*.sql, run byscripts/db-test.shagainst a local Supabase. See Database tests. - Edge functions:
scripts/deno-check-functions.sh [name …]type-checkssupabase/functions/*/index.tswith Deno. - Migration guard:
node scripts/check-migrations.mjs(also runs aspretest).
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 testssrc/services/<service>.test.ts— service-layer testssrc/lib/api.test.ts(+ topic-specific siblings) — backend handler testssrc/components/**/<Component>.test.tsx— component testssupabase/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 1and wraps everything inBEGIN; … ROLLBACK;(nothing persists), - impersonates users with
SET ROLE authenticated+set_config('request.jwt.claims', …), - raises
EXCEPTION 'FAIL …'on a broken expectation andNOTICE '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.mjseight 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 = 553xxlines under[studio],[inbucket],[analytics]andshadow_portunder[db]. - To test against another Postgres major version, change
major_versionin the copy'sconfig.toml(the repo config says 15; the tests pass on 15 and 17). - Re-sync the copy (
cp -R supabase/migrations …again, thensupabase db resetinside$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:
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.mddeclares 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-approvehandlers 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
- Check Node version — CI uses Node 24. Run
node -vlocally. Switch withnvm use 24orfnm use 24. - Delete local caches:
rm -rf node_modules && npm ci. - Check timezone / locale — tests that render dates can differ by TZ. Tests should use
date-fnswith UTC or explicit timezones; if you find a TZ-dependent assertion, fix it. - Flaky test? Re-run the CI job. If it still fails intermittently, mark it with
.todoand open an issue rather than papering over with retries.