Test Suite
Worker integration tests run in the real Cloudflare Workers runtime against an in-memory D1 database. Route coverage, response shapes, and multi-step workflows are validated before deploy.
Overview
| File | Tests | Purpose |
|---|---|---|
api.test.ts | 169 | Every route returns the expected HTTP status code |
shapes.test.ts | 13 | API response JSON matches what frontend components read |
flows.test.ts | 25 | Multi-step workflows that mutate state and verify side effects |
Tests run inside the actual Cloudflare Workers runtime via @cloudflare/vitest-pool-workers. D1 is a real in-memory SQLite instance with all migrations applied. KV and R2 are in-memory miniflare instances. The clock is real and cannot be mocked.
Running Tests
bun run test # single run
bun run test:watch # watch modeRoot ./deploy.sh worker and ./ilx-nivaa-deploy.sh worker run the worker test gate before deploy. If any test fails, the deploy is aborted. ./ilx-nivaa-verify.sh worker is the fast non-deploy equivalent, and browser-tooling scripts remain optional outside the required deploy gate.
Auth in Tests
Tests authenticate using dev headers instead of JWT. The Worker reads these headers only when ENVIRONMENT=development.
| Header | Value | Purpose |
|---|---|---|
x-dev-role | Admin / Tutor / Parent | Sets the role for this request |
x-dev-user-id | e.g. 1, 100, 200 | Sets the authenticated user ID |
x-dev-email | e.g. [email protected] | Sets the email on the auth context |
Seed Data
Each test file has its own beforeAll seed function. Seeds are independent — api.test.ts, shapes.test.ts, and flows.test.ts each insert their own rows and do not share state.
The api.test.ts seed creates a standard set of entities used across all endpoint tests:
| Entity | ID | Notes |
|---|---|---|
| Admin user | id=1 | Default user for admin requests |
| Tutor user | id=100 | Has a tutor_profile with area, subjects, languages |
| Parent user | id=200 | Has phone, address_data |
| Child | id=1 | Belongs to parent 200, level=Primary 4 |
| StudentProfile | id=1 | Links child 1 to parent 200 |
| Match | id=1 | Tutor 100 ↔ StudentProfile 1, status=Parent_Accepted |
| Appointment | id=1 | Scheduled, future date (2026-04-15) |
| Appointment | id=2 | Approved, past date (2026-03-15), 60min |
| PackageLedger | id=1 | Active, 10 sessions purchased, 8 remaining |
| SupportTicket | id=1 | Open, for ticket operation tests |
| RescheduleRequest | id=1 | Proposed, for decide endpoint tests |
| ParentIssue | id=1 | Open, references appointment 2 |
| ReadinessReport | id=1 | Completed, for GET tests |
| Discrepancy | id=1 | Open, references appointment 2 |
| FraudAlert | id=1 | Open, references match 1 |
api.test.ts — Endpoint Status Tests
One test per route. Asserts only the HTTP status code. Response body inspection belongs in shapes.test.ts. Grouped into describe blocks by resource.
- GET
/200 — returns `{ service: 'nivaa-api' }`
- GET
/auth/me200 — returns current user from dev headers - POST
/auth/register201 or 409 on re-run - POST
/auth/login401 for wrong password - POST
/auth/demo-login200 or 500 if demo accounts not seeded - POST
/auth/logout200 - POST
/auth/refresh400 or 401 — no cookie - POST
/auth/change-password401 — wrong current password
- GET
/children/mine200 — parent's children - GET
/children/:id200 — specific child - GET
/children/:id403/404 for wrong parent - GET
/children/overview200 — parent summary
- GET
/intake/tutor200 — tutor intake profile - POST
/intake/tutor200 or 201 - GET
/intake/child/:id200 — child student profile - POST
/intake/child/:id200 or 201
- GET
/matches/mine200 — tutor's active matches - GET
/matching/parent/proposed200 — proposed matches for parent - POST
/matches/:id/terminate200 or 204 — admin terminates
- GET
/appointments/mine200 — tutor's appointments - GET
/appointments/mine200 — parent's appointments - GET
/appointments/:id404 for nonexistent - POST
/appointments/log200 or 201 — tutor logs session - GET
/appointments/:id/detail200 or 403 - PATCH
/appointments/:id/notes200 or 204 - POST
/appointments/:id/checkin400 — outside time window - POST
/appointments/:id/checkout400 — not checked in
- GET
/educator/:id/profile200 for admin - GET
/educator/:id/profile403 for unrelated parent - GET
/educator/:id/profile200 for parent with accepted match - GET
/educator/99999/profile404 for nonexistent
- GET
/tickets/mine200 — user's tickets - POST
/tickets200 or 201 — creates ticket - GET
/tickets/admin200 — admin ticket list - POST
/tickets/:id/comments200 or 201 - GET
/tickets/:id/comments200 — array - POST
/tickets/:id/close200 or 204 - PATCH
/tickets/admin/:id200 or 204
- GET
/readiness/child/:id200 — array - GET
/readiness/check/:id200 - GET
/readiness/:id200 — specific report - POST
/readiness200 or 201 — creates draft - GET
/admin/readiness200 — all reports
- GET
/children/:id/proficiency200 — assessment or topics - POST
/children/:id/proficiency200 or 201 - GET
/tutor/children/:id/proficiency200, 403, or 404
- GET
/reschedule/mine200 — user's requests - POST
/reschedule200, 201, or 409 - POST
/reschedule/:id/decide200 or 404
- GET
/feedback/due200 — pending review prompts - POST
/feedback200 or 201 — submits review - GET
/feedback/admin/flagged200 - GET
/feedback/admin/all200 - PATCH
/feedback/admin/:id200 or 404 - GET
/feedback/tutor/:id/summary200
- GET
/billing/package-summary200 - GET
/billing/records/me200 - GET
/billing/mtd200 - GET
/billing/overdue-check200 - POST
/invoices/:id/dispute200, 201, or 404 - GET
/invoices/:id/sessions200 or 404 - POST
/admin/billing/invoices/:id/mark-paid200, 204, or 404 - POST
/admin/billing/invoices/:id/waive200, 204, or 404
- GET
/payout200 — cycles list - GET
/payout/:id/bank-status200 or 404 - PUT
/admin/tutors/:id/bank-details200 or 204
- GET
/admin/people200 — users with Telegram status - POST
/admin/people/add-parent200, 201, or 409 - POST
/admin/people/add-coordinator200, 201, or 409 - GET
/admin/students200 — paginated student list - GET
/admin/students/counts200 — funnel counts - GET
/admin/students/:id/recent-sessions200 - GET
/admin/students/:id/pool-status200 or 404 - GET
/admin/children/:id/proficiency-history200
- GET
/admin/appointments/operations200 - POST
/admin/appointments/:id/attendance200 or 201 - POST
/admin/appointments/:id/schedule-change200 or 201 - POST
/admin/appointments/:id/flag-mismatch200 or 201
- GET
/admin/postings200 - GET
/admin/postings/aging200 - GET
/admin/postings/:id200 - POST
/admin/postings/:id/close200 or 204 - GET
/admin/matches/:id/incentive200 or 404 - PUT
/admin/matches/:id/incentive200 or 201
- GET
/admin/package-ledgers200 - GET
/admin/packages/renewal-queue200 - POST
/admin/packages/:id/renew200, 201, or 404 - GET
/admin/billing/aging200 - GET
/admin/discounts200 - POST
/admin/discounts200 or 201 - GET
/admin/pricing/catalog200
- GET
/admin/tutors/:id/detail200 - PUT
/admin/tutors/:id/flag200 or 204 - POST
/admin/fraud-alerts/check200 or 201 - GET
/admin/fraud-alerts200 - PATCH
/admin/fraud-alerts/:id200 or 404
- GET
/admin/dashboard200 - GET
/admin/dashboard-counts200 - GET
/admin/leaderboard200 - GET
/admin/session-milestones200 - GET
/admin/session-milestones/summary200 - POST
/admin/session-milestones/:id/complete200 or 404 - GET
/admin/metrics/monthly-hours200 - GET
/admin/metrics/nps-summary200
- 5 Admin endpoints return 403 when called as Tutor
- Admin dashboard returns 403 for Parent
- Other parent's children return 403/404
- GET
/admin/respondio-cache/contacts200 - GET
/admin/respondio-cache/distribution200 - GET
/admin/respondio-cache/analytics200 - GET
/admin/respondio-cache/contacts/:id/messages200 or 404 - POST
/admin/respondio-cache/contacts/:id/messages/refresh200 or 404
- GET
/admin/metrics/revenue-recognition200 - GET
/admin/metrics/margin-split200 - GET
/admin/metrics/parent-tenure200 - GET
/admin/metrics/daily-activity200 - GET
/admin/metrics/adhoc-analytics200 - GET
/admin/metrics/tutor-performance200
- GET
/postings/:id200 for seeded posting - POST
/postings/:id/apply200 — tutor applies to posting - DELETE
/postings/:id/apply200 — tutor withdraws application - POST
/postings201 — parent creates posting - POST
/postings/:id/accept200 or 404 — parent accepts application - POST
/admin/postings/:id/push200 or 404 — admin pushes to parent
- PATCH
/children/:id200 — updates display name - DELETE
/children/:id200 — soft-deletes child
- POST
/appointments/:id/verify200 — parent approves with `action=approve` - POST
/appointments/:id/verify400 — invalid action value rejected
- GET
/auth/singpass/authorize200 — mock response when SINGPASS_CLIENT_ID not set - GET
/auth/singpass/status200 — returns verification status - GET
/auth/singpass/callback400 — missing required params
- POST
/telegram/webhook200/400/500 — invalid payload silently handled or rejected - POST
/telegram/setup-webhookrequires admin auth
- POST
/ops/etl/timetap-importadmin-only endpoint - POST
/ops/etl/update-tutor-addresses200 or 500 — address update runs - POST
/ops/seed-expanded-demo200 or 500 — demo seed runs
- GET
/upload/file/:path404 for nonexistent file - POST
/upload/intake400 when not multipart/form-data - GET
/payout/:id/csv404 for nonexistent cycle
shapes.test.ts — Response Shape Tests
If a frontend component reads data.foo, there must be a test here asserting the API returns foo. Catches silent regressions from backend renames. Each describe block maps to a frontend page or component.
- GET
/educator/:id/profileTop-level fields: `id`, `name`, `gender`, `area`, `employment_type`, `introduction`, `joined`, `profile_photo_url` - GET
/educator/:id/profileArrays are arrays (never null): `languages`, `subjects`, `special_needs_experience`, `moe_syllabus`, `experience`, `education`, `certifications`, `testimonials` - GET
/educator/:id/profile`experience_years` has `eip`, `international_school`, `moe`, `total` (number) - GET
/educator/:id/profile`stats` has `active_matches`, `total_sessions` - GET
/educator/:id/profileExperience entries: `role`, `organization`, `description`, `period_start`, `period_end` - GET
/educator/:id/profileEducation entries: `qualification`, `institution`, `details`, `period_start`, `period_end` - GET
/educator/:id/profileCertifications: `name`, `issuer`, `year` - GET
/educator/:id/profileTestimonials: `text` (non-empty string)
- GET
/tutor/points`total_points` (number), `breakdown.sessions/students/uploads/distance` - GET
/tutor/points`rank`, `total_tutors`, `badges[]`, `monthly_history[]`, `stats.total_sessions/total_students`
- GET
/tutor/leaderboard`leaderboard[]`, `my_position` - GET
/admin/dashboard`counts.students/educators/parents/open_postings`, `postings_needing_attention[]` - GET
/children/mine`id` (number), `display_name` (string) - GET
/matches/mine`child_name`, `status` - GET
/postings/browse`id`, `subject`, `fit_score`, `location_match` - GET
/billing/package-summaryArray of package objects - GET
/alerts/parentStructured object (type check)
flows.test.ts — Workflow Tests
Each test is a single large it() block covering a complete lifecycle. Steps are sequential; each step's result feeds the next. Direct DB access is used alongside HTTP calls to set up state that can't be reached through HTTP alone (e.g. bypassing time-window checks on check-in/out by writing timestamps directly).
- Full approval path: `Scheduled → Checked_In → Awaiting_Approval_Parent → Approved` — package deducted (FIFO)
- Dispute + reversal: approve session → dispute → package session count restored, appointment unlinked
- Future session guard: confirm endpoint rejects sessions with future date (400)
- Two packages for same child (different `created_at`): oldest deducted first, newer untouched
- Parent posts → tutor applies → admin pushes → parent sees blind educator cards → parent accepts → match created (`Parent_Accepted`) → posting becomes `Filled` → first-session discount created
- Seed approved appointment → generate Draft payout cycle → finalize → fetch CSV → OCBC-format with tutor rows
- RBAC: tutor cannot generate payout cycle (403)
- Full lifecycle: tutor creates → lists → admin lists → admin approves with notes
- Validation: invalid type → 400, empty description → 400, invalid approval status → 400
- Create → update name → verify persisted → soft-delete → verify gone from list
- Parent creates → parent lists → admin lists
- Parent creates posting → sees in list
- Tutor applies → sees in applications → withdraws → reflected immediately
- Tutor blocked from 5 admin endpoints (403)
- Parent blocked from admin dashboard (403)
- Parent cannot see other parent's children (403/404)
- Tutor cannot see unmatched educator profile (403)
- Admin can see any educator profile (200)
- Matched parent can see their tutor (200)
- Unmatched parent blocked from tutor profile (403)
- POST /auth/register (role=Tutor) → POST /intake/tutor with subjects, schedule, nric_name, languages → profile retrievable → tutor in admin /people list → locked fields (nric_name) immutable after first save, normal fields (area) update freely
- 16-step flow: create child → attempt English posting without assessment → 422 (requires_assessment: true) → complete proficiency assessment → create posting with assessment ID → tutor applies → admin sees application → admin pushes → parent sees blind card → parent accepts → verify match Parent_Accepted + posting Filled + first-session discount → seed package → DB simulate check-in/check-out → parent confirms → verify Approved, 10→9 package deduction, appointment linked to package
- Auto-link: create child with recent Maths assessment → create posting without proficiency_assessment_id → backend auto-links existing recent assessment, returns 201 with assessment ID set
Adding Tests for New Features
- 1 New route: Add a status-code test to
api.test.ts. Seed any required rows in theseed()function at the top of the file. If Admin-only, also add a 403 test with a lower-privileged role. - 2 New response field: Add
toHavePropertyassertions to the relevantdescribeblock inshapes.test.ts. Assert the type if the frontend uses it as a specific type. AssertArray.isArray()not justtoHavePropertyfor arrays. - 3 New multi-step workflow: Add a single
it()block toflows.test.ts. Walk through each state transition via HTTP. After each mutating call, query the DB directly and assert the expected state. - 4 New migration: Regenerate
src/__tests__/setup.tsor the test D1 won't have the new table and every test touching it will fail withno such table.
Common Pitfalls
- CHECK constraint violations cause 500, not 400. Look up allowed enum values in the migration before writing test body data.
- The clock cannot be mocked in the CF Workers runtime. Bypass time-gated actions (check-in, check-out) by writing timestamps directly to the DB, then test the subsequent HTTP action.
- Shared D1 state — all
it()blocks in a file share the same database instance. UseRETURNING idand dynamic IDs rather than hardcoded ones to avoid inter-test conflicts. - Lowercase enums — string CHECK constraints are case-sensitive.
'trial'not'Trial'.'AdHoc'not'adhoc'. - JSON-serialised columns — some columns store JSON strings (
languages,subjects). If a shape test gets a string where it expects an array, the backend is returning raw JSON instead of parsing it. - FK constraints — inserts require valid foreign keys.
fraud_alertsneedsmatch_id,parent_user_id,tutor_user_id. Check the migration before seeding. - Role mismatches — some endpoints require
Parent, notAdmin. Check the route handler'srequireRole()call before assuming Admin works.