Skip to content

Testing

The repo has 570+ tests. 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: TypeScript strict mode. Run with npx tsc --noEmit.

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
npx tsc --noEmit

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

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.