# Domain test checklist — guaranteed behaviors Living definition-of-done. Each domain lists the behaviors its tests guarantee. When you change a domain, its box must still hold (or you update this list on purpose). Baseline established 2026-07-22 (see plan `2026-07-22-per-domain-test-safety-net`). Tracked as Plane epic **SL-70**. ## Coverage baseline (Phase A, 2026-07-22) | Workspace | % Stmts | % Branch | % Funcs | % Lines | | --------- | ------- | -------- | ------- | ------- | | api | 96.94 | 74.29 | 100 | 96.94 | | worker | 89.62 | 87.42 | 70.58 | 89.62 | | admin | 92.43 | 84.91 | 78.75 | 92.43 | Target after the sweep: near-100% per domain; remaining uncovered lines annotated `/* c8 ignore next -- */`, not left silent. ## Infra (health/db/schema/seed/env/migrate) Src covered: `src/routes/health.ts`, `src/db/client.ts`, `src/db/ensure-dir.ts`, `src/db/migrate.ts`, `src/db/seed.ts`, `src/env.ts` — all at 100% stmts/branch/funcs/lines. (`src/db/schema.ts` is generated and excluded from coverage by config.) Guaranteed behaviors: - `GET /health` returns `{ status: 'ok' }`. - `env.ts` applies every dev default (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `PORT`, `WEB_ORIGINS`) when the vars are unset, and reads each override when set (`PORT` coerced to a number). - `env.WEB_ORIGINS` parses `CORS_ORIGINS` into a list, trimming whitespace and dropping empty entries; falls back to the default localhost origins when `CORS_ORIGINS` is empty/unset. - `runMigrations()` is idempotent — re-running it against an already-migrated DB does not throw. - `ensureDbDir()` is a no-op for non-`file:` URLs and for a bare filename in the current dir. - `seed()` seeds the reference activities idempotently and seeds the dev worker + dev admin with the correct roles, idempotently. - `seed()` never creates the dev users when `NODE_ENV=production` (reference activities still seeded). - The `migrate.ts` / `seed.ts` CLI direct-run blocks are `c8 ignore`d — unreachable under the test runner (exercised only via `tsx src/db/{migrate,seed}.ts`). ## Auth & /me Src covered: `src/auth.ts`, `src/routes/me.ts`, `src/lib/require-user.ts`, and the CORS middleware in `src/app.ts` — all at 100% stmts/branch/funcs/lines. Guaranteed behaviors: - Sign-in with valid credentials returns a bearer token (`set-auth-token` header); sign-in with a wrong password fails (status ≥ 400). - Public sign-up is rejected (`disableSignUp` — admin creates users). - `GET /api/me` returns 401 without a token, and `{ user: { id, email, name, role } }` with a valid bearer; role reflects the user (`worker` / `admin`). - `GET /api/me` falls back to role `'worker'` when the session user has no role set. - `getSessionUser` maps `id`, `role`, and `banned` from the session; defaults `role` to `'worker'` and `banned` to `false` when unset; returns `null` when there is no session. - `isAdmin` is true only for the `admin` role (false for `worker` and for `null`). - `adminGuard` returns 401 without a session, 403 for a non-admin, and 403 for a banned admin that still holds a valid token; it passes through for an active admin. (Exercised through the `/api/admin/*` surface in `admin-users.test.ts`.) - CORS: the allowed SPA origins (worker 5173, admin 5174) are echoed on the `access-control-allow-origin` header, OPTIONS preflight is answered with the allowed methods, and `set-auth-token` is exposed so the SPA can read the bearer on sign-in. ## Activities Src covered: `src/routes/activities.ts` — 100% stmts/branch/funcs/lines. The one unreachable branch (`row.sortOrder ?? 0` in `toActivity`; `sort_order` is `NOT NULL DEFAULT 0`) is `c8 ignore`d with a reason, not left silent. Guaranteed behaviors: - `GET /api/activities` requires a session (401 without a token); any authenticated user — worker or admin — may read. - The list is ordered by `sort_order` then `name`, and `?insole_type=` filters to activities whose `insole_types` include that type. - `POST`/`PUT`/`DELETE`/`PUT /reorder` are admin-only: 401 without a token, 403 for a worker. - Create validates a non-empty (trimmed) `name` (400 otherwise) and defaults `insole_types` to all three (`Kurk`, `Berk`, `3D`) when omitted. - Create appends the new activity with a `sort_order` one higher than the current maximum. - `insole_types` round-trips through create/list as the exact stored subset. - Update (`PUT /:id`) 404s for a non-numeric or unknown id, 400s on an invalid body, and returns the updated activity otherwise. - Delete (`DELETE /:id`) 404s for a non-numeric id, removes the activity **and** its work-session rows (no FK cascade), and returns `{ success: true }`. - Reorder (`PUT /reorder`) sets `sort_order` from the `ids` array position; it 400s on an empty array (zod `min(1)`) and when the ids are not exactly the full known set (wrong count or an unknown id). - Lifecycle: an admin creates three activities → reorders them → a worker's GET reflects the new order → the admin deletes one → it is gone from the list. ## Work-sessions Src covered: `src/routes/sessions.ts` and `src/lib/work-session.ts` — both at 100% stmts/branch/funcs/lines. The two `row.pausedSeconds ?? 0` guards in `sessions.ts` (resume/stop) are `c8 ignore`d with a reason — `paused_seconds` is `NOT NULL DEFAULT 0`, so the null branch is unreachable via any route. Guaranteed behaviors: - `start`, `pause`, `resume`, `stop`, `discard` all require a session (401 without a token), as do the reads `GET /api/sessions` and `GET /api/sessions/active`. - `POST /api/sessions/start`: 200 creates an `active`, `source: 'app'` session (null `end_time` / `duration_seconds`); 400 on an invalid body **and** on a malformed-JSON body; 404 when the `activity_id` does not exist. - `POST /:id/pause`: 200 sets `paused_at` on a running session; 404 for a non-numeric or unknown id; 409 on a double pause (already paused) and 409 when the session is no longer active. - `POST /:id/resume`: 200 clears `paused_at` and accumulates `paused_seconds`; 404 for a non-numeric or unknown id; 409 when the session is running (not paused) and 409 when it is no longer active. - `POST /:id/stop`: 200 completes the session, folds an open pause span into `paused_seconds`, and computes `duration_seconds = wall-clock − paused_seconds` (never negative); a never-paused stop keeps `paused_seconds = 0` and `duration = wall-clock`; 404 for a non-numeric/unknown id; 409 when already closed. - `POST /:id/discard`: 200 marks the session `discarded` with a null `duration_seconds`; 404 for a non-numeric/unknown id; 409 when already closed. - Ownership scoping: user B can neither stop nor discard user A's session — both return 404 and leave A's session untouched (still `active`). - Reads: `GET /api/sessions` returns the caller's sessions only, newest-first, joined to the activity `name`; `GET /api/sessions/active` returns only that user's `active` sessions. - `toWorkSession` maps every field to the API contract (incl. `paused_at`, `paused_seconds`, `notes`, `created_at`), threads `activity_name` / `user_name` / `user_email` opts through (undefined when omitted), maps a null `insoleType` / `durationSeconds` / `notes` to null, and coalesces a nullish `paused_seconds` to 0. - Lifecycle: start → pause → (backdate) resume accumulates paused time → (backdate) stop excludes paused time from worked duration → the completed session appears in history with its activity name → `GET /api/export` returns a CSV with exactly the header + one data row. ## Worker export/CSV Src covered: `src/lib/csv.ts` — 100% stmts/branch/funcs/lines; the `/api/export` route in `src/routes/sessions.ts` — 100%. No `c8 ignore`s needed (every fallback branch is exercised). Guaranteed behaviors: - `buildSessionsCsv(rows)` (worker self-export, `includeWorker` defaults to false) emits the exact legacy header `ID,Task,Insole Type,No. of Insoles,Date,Total Duration,Paused Duration,Start Time,End Time` (each cell quoted) with no Worker column. - `buildSessionsCsv(rows, { includeWorker: true })` prepends a `Worker` column carrying `userName`. - Every field is quoted; `quote` escapes embedded double quotes (doubled) and keeps commas and newlines inside the quoted cell — a comma/quote/newline in an activity name never breaks the column or row structure. - Legacy fallbacks: a null `activityName`/`userName` renders `''`, null `insoleType` renders `Kurk`, null `pairCount` renders `2`, null `durationSeconds`/`pausedSeconds` render `00:00:00`, and a null `endTime` renders an empty End Time cell. - `formatDuration` renders zero-padded `HH:MM:SS`, handles zero (`00:00:00`), and lets hours exceed 99 (`360000 → 100:00:00`). - `GET /api/export`: 401 without a token; includes only the caller's `completed` sessions (active/discarded and other users' rows excluded); rows ordered by start time ascending; sets the `text/csv` content-type and the `attachment; filename="insole-production-report.csv"` disposition header. - Lifecycle: covered by the worker-session anchor (`test/integration/worker-session-lifecycle.test.ts`), whose final step asserts `GET /api/export` returns the header + exactly one completed-session row. ## Admin sessions Src covered: the session parts of `src/routes/admin.ts` (cross-user list, active list, manual create/edit, admin stop/discard) — all session-part statements, lines, and branches covered, so `admin.ts` is at 100% stmts/lines. The one `row.pausedSeconds ?? 0` guard in the admin stop route is `c8 ignore`d with a reason — `paused_seconds` is `NOT NULL DEFAULT 0`, so the null branch is DB-impossible. (The report/export parts of `admin.ts` — `parseReportQuery`, the aggregation `??` fallbacks, and the export invalid-query branch — are owned and covered by the Reports domain.) Guaranteed behaviors: - Every `/api/admin/*` session route is gated: 401 without a token, 403 for a worker. - `GET /api/admin/sessions` returns **all** users' sessions newest-first, each joined to `activity_name`, `user_name`, and `user_email`; `GET /api/admin/sessions/active` is the same but filtered to `active` sessions only. - `POST /api/admin/sessions` (manual create): creates a `completed`, `source: 'manual'` session with `duration_seconds = span − paused_seconds` and null `paused_at`; 400 on a schema-invalid body, on `end_time < start_time`, and when `paused_seconds` exceeds the worked span; 404 for an unknown `user_id` and for an unknown `activity_id`. - `PUT /api/admin/sessions/:id` (edit): recomputes `duration_seconds` when `end_time` is present and sets it null when `end_time` is null (re-activating); 404 for a non-numeric or unknown id; 400 on a schema-invalid body, on an unparseable `start_time`, on `end_time < start_time`, and when `paused_seconds` exceeds the span; 404 for an unknown `activity_id`. - `POST /api/admin/sessions/:id/stop`: completes another user's active session, folds an open pause span into `paused_seconds`, clears `paused_at`, and computes `duration_seconds` excluding paused time; 404 for a non-numeric or unknown id; 409 when the session is already closed. - `POST /api/admin/sessions/:id/discard`: marks another user's active session `discarded` with an `end_time`; 404 for a non-numeric or unknown id; 409 when the session is already closed. - Lifecycle (`test/integration/admin-worker-session.test.ts`): a worker starts a session → an admin stops it via the admin endpoint → the completed session appears in `GET /api/admin/report` (totals, `by_worker`, `by_activity`, `by_type` all matching the stored row) and in `GET /api/admin/export` (header + exactly one row, with the leading Worker column). ## Reports + admin export Src covered: the report/export parts of `src/routes/admin.ts` (`parseReportQuery`, `buildSessionFilters`, `GET /api/admin/report`, `GET /api/admin/export`) — `admin.ts` is at 100% stmts/branch/funcs/lines. Three defensive aggregation `??` guards are `c8 ignore`d with reasons rather than left silent: `pausedSeconds`/`pairCount` are `NOT NULL` (DB-impossible nulls), and the `user_name`/`activity_name` fallbacks are unreachable because the `user_id` FK cascades on delete and the `activity_id` FK is enforced (every session always joins to a real user and activity). Guaranteed behaviors: - Both `GET /api/admin/report` and `GET /api/admin/export` are gated: 401 without a token, 403 for a worker. - Query parsing (`parseReportQuery`, shared by report + export): 400 when `from`/`to` is missing, unparseable, or inverted (`to < from`); 400 on an `insole_type` outside `Kurk`/`Berk`/`3D`; 400 on a non-numeric `activity_id`; the export route rejects the same bad queries with 400. - Report scope: only `completed` sessions inside `[from, to]` (by `start_time`) are counted — active/discarded rows are excluded. - Filters narrow the result set: `user_id`, `insole_type`, and `activity_id` each restrict the counted sessions (individually and combined). - Totals sum `worked_seconds`, `paused_seconds`, `pairs`, and `sessions` across every matched row, and equal the sum of each breakdown. - Breakdowns `by_worker`, `by_activity`, and `by_type` group correctly and are each sorted by `worked_seconds` descending. - A completed session with a null `insole_type` is bucketed under `"Onbekend"` in `by_type` (worker and activity buckets stay real); a completed session with a null `duration_seconds` contributes `0` worked seconds. - An empty range yields zero totals and empty `by_worker`/`by_activity`/`by_type` arrays. - Admin export: emits an all-users CSV with the leading `Worker` column; the `text/csv` content-type and a `Content-Disposition` filename of `solelog-report__.csv` (dates sliced to `YYYY-MM-DD`); rows ordered by start time ascending; honors the same filters as the report. - Lifecycle (`test/integration/report-aggregation.test.ts`): seed known completed sessions for two workers × two activities × two insole types → assert exact totals and every `by_worker`, `by_activity`, `by_type` row (values and descending order) → apply a `user_id` filter → assert the totals and breakdowns narrow to that worker only. ## Admin users Src covered: `src/routes/admin-users.ts` — 100% stmts/branch/funcs/lines. The two last-admin guards (in the role and deactivate routes) are `c8 ignore`d with reasons: they are unreachable over HTTP because the self-demote / self-deactivate guards above already handle `caller === target`, so any caller reaching them is a distinct active admin and `activeAdminsExcluding(target) >= 1`; the guard logic itself is unit-tested through `activeAdminsExcluding`. Guaranteed behaviors: - Every `/api/admin/users*` route is gated: 401 without a token, 403 for a worker, and 403 for a banned admin that still holds a valid token (`adminGuard` defense-in-depth). - `GET /api/admin/users` returns every user ordered by name, each mapped by `toListItem` to `{ id, email, name, role, status, created_at }`; a null DB `role` falls back to `worker` and a `banned` row surfaces as `status: 'inactive'`. - `POST /api/admin/users` creates a user who can then sign in; 409 on a duplicate email; 400 on a schema-invalid body (incl. a password shorter than 8 chars); a mixed-case email is stored lowercased and the created row is returned (no 500 on the re-select). - `POST /:id/role` promotes/demotes and returns the updated row; 400 on an invalid role body; 404 on an unknown id; refuses self-demotion (400); allows demoting a peer admin while the caller stays admin (200). - `activeAdminsExcluding` (unit) counts only active (non-banned) admins and honours the excluded id. - `POST /:id/deactivate` bans the user, clears any live session rows, and returns `status: 'inactive'`; 404 on an unknown id; refuses self-deactivation (400); allows deactivating a peer admin while always blocking self. - `POST /:id/reactivate` clears the ban and returns `status: 'active'`; 404 on an unknown id. - `POST /:id/password` sets a new password (new works, old fails); 400 on a too-short password; 404 on an unknown id; surfaces a 400 (not a 500) when better-auth's `setUserPassword` throws. - Lifecycle (`test/integration/user-lifecycle.test.ts`): admin creates a worker → worker signs in and the bearer authorizes `/api/me` → admin deactivates → the session rows are deleted so the pre-issued token no longer authorizes `/api/me` (401) **and** a fresh sign-in is blocked → admin reactivates → the ban is cleared and sign-in works again. ## Auth (context/storage/login) — worker client Src covered (worker `apps/worker`): `src/auth/AuthContext.tsx`, `src/lib/auth-storage.ts`, and `src/screens/Login.tsx` — all at 100% stmts/branch/funcs/lines. No `c8 ignore`s needed. Guaranteed behaviors: - `auth-storage`: `setToken` persists the bearer, `getToken` reads it back, `clearToken` removes it (`getToken` returns `null` when nothing is stored) — all via `localStorage`. - `AuthContext` starts **unauthenticated** when no token is stored and **restores** the authenticated state on mount when a token is already present (`getToken() !== null`). - `AuthContext.signIn` delegates to the api `signIn(email, password)` and, on success, flips the context to authenticated (`isAuthed` → true). - `AuthContext.signOut` resets the router path to `/` (so the next authed mount lands on Stopwatch, not Account), clears the stored token, and flips the context to unauthenticated. - `useAuth` throws `"useAuth must be used within an AuthProvider"` when called outside the provider. - `Login` renders the Dutch form (E-mailadres / Wachtwoord / Inloggen) and submits the typed credentials to `POST /api/auth/sign-in/email`, storing the returned bearer token. - `Login` shows the `"Inloggen mislukt"` error (and stores no token) when sign-in fails, and disables the submit button while the request is pending, re-enabling it once it settles. - Lifecycle (`src/screens/Login.test.tsx`): from the unauthenticated app, a successful login stores the token and flips the context so the tabbed authenticated shell (Stopwatch + TabBar) renders. > Note: the design checklist mentions "clears on 401" for the worker auth context — this is **not > implemented** in the current code (there is no global 401 interceptor; `apiFetch` throws > `ApiError` and callers surface it via React Query). Left as-is (missing feature, not a defect); > not tested and not filed as a bug. ## History — worker client Src covered (worker `apps/worker`): `src/screens/History.tsx` and `src/lib/export.ts` — both at 100% stmts/branch/funcs/lines. No `c8 ignore`s needed. Guaranteed behaviors: - History renders the Dutch header (`Geschiedenis`) and the `Exporteer CSV` button. - Sessions render in the order the API returns them (`GET /api/sessions` is newest-first), one `.session-card` per session, showing the activity name and its formatted duration. - `formatDuration` renders the legacy format: `Hh Mm` when hours are present (`3661 → 1h 1m`), `Mm Ss` when only minutes (`125 → 2m 5s`), and just seconds under a minute (`45 → 45s`); a null `duration_seconds` renders `0s`. - A card falls back to `"Onbekende handeling"` when `activity_name` is absent, omits the insole-type pill when `insole_type` is null, and uses the singular noun (`inlegzool`) only for a pair count of 1 (`inlegzolen` otherwise). - A `Pauze ` line shows only when `paused_seconds > 0` (absent when it is 0). - The empty state (`Nog geen opgeslagen sessies.`) shows only once loading has finished and there are no sessions; while the query is still loading, neither the empty state nor any cards render. - Clicking `Exporteer CSV` calls `downloadExport`; when it rejects, the card surfaces the `Kan de export niet openen` error message instead of throwing. - `downloadExport` (`src/lib/export.ts`): fetches `GET /api/export` with the bearer token when one is stored (no `Authorization` header when there is none), then triggers a client-side download via an object URL (`createObjectURL` → anchor `click` → `revokeObjectURL`); it throws `"Kan de export niet openen"` when the response is not ok. - Lifecycle (`src/screens/History.test.tsx`): the list renders a completed session showing both its worked duration and its `Pauze` line, then the export action invokes the download path. ## Account — worker client Src covered (worker `apps/worker`): `src/screens/Account.tsx` — 100% stmts/branch/funcs/lines. No `c8 ignore`s needed. Guaranteed behaviors: - Account renders the Dutch header (`Account`) and, once `GET /api/me` resolves, shows the signed-in user's name and email from `useMe()`. - The fallback message `Kon accountgegevens niet laden.` shows when the query resolves without a `user` (and the name/email are then absent). - Clicking `Uitloggen` calls `signOut`, which clears the stored bearer token. - Lifecycle (`src/screens/Account.test.tsx`): from the authenticated app on the Account screen, clicking `Uitloggen` flips the auth context to unauthenticated so the login form (`E-mailadres`) renders and the `Uitloggen` button is gone (token cleared). ## lib/api + app/nav — worker client Src covered (worker `apps/worker`): `src/lib/api.ts`, `src/App.tsx`, `src/components/TabBar.tsx`, `src/api/sessions.ts`, `src/api/activities.ts`, `src/api/me.ts` — all 100% stmts/branch/funcs/lines. No `c8 ignore`s needed. Guaranteed behaviors: - `apiFetch` attaches `Authorization: Bearer ` when a token is stored and omits it otherwise. - `apiFetch` sets `Content-Type: application/json` when a request has a body and no explicit Content-Type, and does not override a Content-Type the caller supplied. - `apiFetch` parses a JSON response body, and returns `undefined` when the body is empty. - `apiFetch` throws `ApiError` (carrying the HTTP status) on any non-2xx response. - `signIn` POSTs to `/api/auth/sign-in/email`, stores the token from the `set-auth-token` header, and throws `ApiError` when the response is not ok or the header is missing. - `App` gates on auth state: no token renders `Login` (no tab bar); a token renders the authed shell with the Stopwatch screen mounted at `/` beside the `TabBar`. - `TabBar` renders the three tabs (`Stopwatch` → `/`, `Geschiedenis` → `/history`, `Account` → `/account`) as links, marks the current route's tab `tab-active`, and uses `end` matching so the Stopwatch tab is active only on the root path. - Each `api/*` hook hits the right path/method: `useSessions` → GET `/api/sessions`; `useActiveSessions` → GET `/api/sessions/active`; `useStartSession` → POST `/api/sessions/start` with the serialized input; `useStop/Discard/Pause/ResumeSession` → POST `/api/sessions/:id/`; `useActivities` → GET `/api/activities`; `useMe` → GET `/api/me`. Every mutation invalidates the `['sessions']` query cache on success. - Lifecycle (`src/App.test.tsx`): unauthenticated renders `Login` with no navigation; authenticated renders the Stopwatch screen under the tab bar and no login form.