Files
solelog/docs/reference/domain-test-checklist.md
Bas van Rossem 472fcf82b8
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m48s
docs: finalize domain-test-checklist + session log + roadmap (test safety net, 100% coverage)
2026-07-23 15:40:49 +02:00

617 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) → final (Phase C), 2026-07-22
| Workspace | Stmts (before → after) | Branch (before → after) | Funcs (before → after) | Tests (before → after) |
| --------- | ---------------------- | ----------------------- | ---------------------- | ---------------------- |
| api | 96.94 → **100** | 74.29 → **100** | 100 → **100** | 103 → **175** |
| worker | 89.62 → **100** | 87.42 → **100** | 70.58 → **100** | 30 → **79** |
| admin | 92.43 → **100** | 84.91 → **100** | 78.75 → **100** | 50 → **129** |
All three workspaces reach **100%** statements/branches/functions/lines. Genuinely
unreachable/defensive lines are annotated `/* c8 ignore next -- <reason> */` (never left silent).
No bugs were found during the sweep — every added test locks in already-correct behavior.
<!-- Phase B appends one "## <domain>" section per domain below. -->
## 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=<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_<from>_<to>.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 <duration>` 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 <token>` 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/<action>`;
`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.
## Auth (admin-only gate) — admin client
Src covered (admin `apps/admin`): `src/auth/AuthContext.tsx` and `src/screens/Login.tsx` — both at
100% stmts/branch/funcs/lines. No `c8 ignore`s needed.
Guaranteed behaviors:
- `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)`, then fetches `/api/me`:
**admin-only gate** — an `admin` role flips the context to authenticated; a non-admin role clears the
stored token and throws `NotAdminError` (`"not-admin"`), leaving the context unauthenticated.
- `AuthContext.signOut` resets the router path to `/` (so the next authed mount lands on Live, not
whatever tab the admin logged out from), clears the stored token, and flips 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 `signIn`.
- `Login` distinguishes the two failure modes: a `NotAdminError` surfaces
`"Geen toegang — alleen beheerders."` while any other failure surfaces `"Inloggen mislukt"`; a stale
error is cleared on the next submit.
- `Login` disables the submit button while the sign-in is pending, re-enabling it once it settles.
- Lifecycle (`src/screens/Login.test.tsx`): from the unauthenticated app, worker credentials are
rejected with the `"Geen toegang — alleen beheerders."` message and the shell never renders; admin
credentials then flip the gate so the authed shell (Sidebar nav) renders.
## Live view — admin client
Src covered (admin `apps/admin`): `src/screens/Live.tsx` and `src/lib/elapsed.ts` — both at
100% stmts/branch/funcs/lines. No `c8 ignore`s needed.
Guaranteed behaviors:
- Live renders one `.live-card` per active session under a `Actief nu (<count>)` header, each card
showing the worker name, the activity, the pair count, and a running worked-time timer.
- The per-card worked time is server-authoritative: `worked = (base − start_time) − paused_seconds`,
where `base` is `now` while running (a 1s client tick counts it up between the 5s server polls) and
the frozen `paused_at` moment while paused, floored at 0. `formatTime` (in `elapsed.ts`) renders it
as zero-padded `HH:MM:SS` and never returns a negative time.
- A running (not-paused) card's timer keeps counting up on the 1s tick; a paused card shows the
`Gepauzeerd` badge and its worked timer stays frozen at the pause moment across ticks.
- The `Pauze <duration>` total line shows only when `paused_seconds > 0` (absent when it is 0).
- Placeholder fallbacks: a missing `user_name` renders `Onbekend`, a missing `activity_name` renders
`Onbekende handeling`, and a null `insole_type` omits the type pill.
- Each card exposes `Stop` and `Annuleer` buttons that call `POST /api/admin/sessions/:id/stop` and
`.../discard` for that session; both buttons are disabled while either mutation is in flight.
- Loading state renders `Laden…`; the error state renders `Kon gegevens niet laden.` (and no header);
the empty state renders `Niemand is nu aan het werk.`; a non-array payload is treated as zero active
sessions (defensive `Array.isArray` fallback).
- Lifecycle (`src/screens/Live.test.tsx`): active-sessions data renders a running card and a paused
card side by side, with exactly the paused session showing the `Gepauzeerd` badge and its
`Pauze 00:01:30` total.
## Sessions + SessionForm — admin client
Src covered (admin `apps/admin`): `src/screens/Sessions.tsx` and `src/components/SessionForm.tsx` —
both at 100% stmts/branch/funcs/lines. One `c8 ignore` with a reason: `localToIso`'s
`Number.isNaN` guard is unreachable because a `datetime-local` input only ever yields a valid
datetime string or `''`.
Guaranteed behaviors:
- Sessions renders one `.sessions-row` per session (worker, activity + insole-type chip, worked
time, start date, status badge). Row worked-time is server-authoritative: completed/discarded rows
use `duration_seconds`; an active row counts `(base − start) − paused_seconds` where `base` is the
frozen `paused_at` moment while paused (else `now`), floored at 0.
- The `Status` filter narrows the list to `all`/`active`/`completed`/`discarded`; the empty message is
`Nog geen sessies.` when there are truly none and `Geen sessies in dit filter.` when the filter
hides every row.
- The query's error state renders `Kon sessies niet laden.` (no list); the loading state renders
`Laden…`; a non-array payload is treated as zero sessions.
- Row placeholders: a missing `user_name` renders `Onbekend` (and the edit aria-label uses
`onbekend`), a missing `activity_name` renders `Onbekende handeling`, a null `insole_type` omits the
type chip, and an unparseable `start_time` renders an empty date cell; the `Pauze <time>` line shows
only when `paused_seconds > 0`.
- `Stop` and `Annuleer` appear only on `active` rows and call `POST /api/admin/sessions/:id/stop` and
`.../discard`; the `✎` edit button appears on every row and opens the shared form in edit mode.
- `+ Nieuwe registratie` opens the shared form in create mode; cancelling (`Annuleren`) closes it and
returns to the list.
- SessionForm create mode shows the medewerker picker (hidden in edit mode) and POSTs a
`CreateManualSessionInput`; edit mode hides the picker, shows a `Status` select, prefills from the
session, and PUTs an `AdminUpdateSessionInput`.
- Create validation: an empty handeling/starttijd shows `Vul handeling en starttijd in.` and blocks the
POST; a filled handeling+starttijd but missing medewerker/zooltype/eindtijd shows
`Vul medewerker, zooltype, start- en eindtijd in.`.
- Serialization: the notitie is trimmed (empty → `null`); in edit mode an empty zooltype serializes to
`insole_type: null` and status `active` serializes `end_time: null`; `Aantal paren` floors at 1 and
`Pauze (minuten)` floors at 0 on invalid/empty input.
- The gewerkt preview renders `end − start − paused` (`—` when the span is indeterminate); a
prefilled unparseable date falls back to an empty field; a 400 from the API surfaces
`Controleer de ingevoerde gegevens …` while any other failure surfaces `Opslaan mislukt …`.
- Edit mode without a session is a defensive no-op on submit (no PUT is issued).
- Lifecycle (`src/screens/Sessions.test.tsx`): open the create form → fill a valid manual session →
submit → it appears in the list; then edit that row (change the handeling) → the change persists in
the refreshed list.
## Reports + date-range — admin client
Src covered (admin `apps/admin`): `src/screens/Reports.tsx`, `src/api/reports.ts`, and
`src/lib/date-range.ts` — all three at 100% stmts/branch/funcs/lines. No `c8 ignore` needed.
Guaranteed behaviors:
- Date-range helpers derive ISO instants in the admin's local timezone: `dayStartISO` → local
00:00:00.000 of the picked day, `dayEndISO` → local 23:59:59.999; `toDateStr` zero-pads month/day.
- Presets map to the correct `YYYY-MM-DD` windows: `thisWeek` = Monday..today, `thisMonth` =
1st..today, `allTime` = fixed floor `2000-01-01`..today.
- `filtersToQuery` always serializes `from`/`to` and only emits `user_id`/`insole_type`/`activity_id`
when set (empty optionals are omitted).
- `useReport` calls `GET /api/admin/report?<query>`; clicking a preset (`Deze week`/`Deze maand`/
`Alles`), changing the Van/Tot date inputs, or picking a Medewerker/Type/Handeling filter re-issues
the report query with the new params (verified against the emitted querystring).
- The screen renders headline totals (`worked`/`pairs`/`sessions`/`paused`) and the three breakdown
tables (`Per medewerker`/`Per handeling`/`Per type`) from the response rows.
- Loading state renders `Laden…`; the query error state renders `Kon rapport niet laden.`; a report
with no data falls back to zero totals and shows `Geen gegevens.` in every table (the `?? 0` / `?? []`
defensive fallbacks).
- `Exporteer CSV` calls `downloadExport` with the current filters; a rejected download surfaces
`Export mislukt. Probeer opnieuw.`.
- `downloadExport` fetches `/api/admin/export?<query>` with a `Bearer` header when a token exists (and
no `Authorization` header when it doesn't), throws on a non-ok response, names the file from the
`content-disposition` header, and falls back to `solelog-report.csv` when that header is absent.
- Lifecycle (`src/screens/Reports.test.tsx`): pick `Deze maand` + a Medewerker filter → the report
query reflects both (`from` = this-month start, `user_id` set) and the returned rows still render.
## Users — admin client
Src covered (admin `apps/admin`): `src/screens/Users.tsx`, `src/components/UserForm.tsx`, and
`src/api/users.ts` — `UserForm.tsx` and `users.ts` at 100% stmts/branch/funcs/lines; `Users.tsx` at
100% stmts/branch/funcs/lines. Two `c8 ignore`s with reasons in `Users.tsx`: the `usersQuery.data ?? []`
fallback (defensive — the isLoading/isError branches return above it, so `data` is always set) and the
`if (pw.length < 8) return;` guard in `onReset` (defensive — the Opslaan button is disabled while the
password is shorter than 8, so it never fires).
Guaranteed behaviors:
- The screen lists one row per user with a role pill (`Beheerder`/`Werker`) and a status pill
(`Actief`/`Inactief`), plus name, email, and a locale-formatted created date.
- The signed-in admin's own row (matched by `useMe().user.id`) shows a `jij` badge and hides the
role-toggle and deactivate/reactivate actions (a user can never demote or deactivate themselves via
the UI); other rows show those actions.
- The loading state renders `Laden…` and the query error state renders `Kon gebruikers niet laden.`.
- `+ Nieuwe gebruiker` toggles the create form; submitting it POSTs a `CreateUserInput`
(`{ name, email, password, role }`) to `/api/admin/users`; `Annuleer` closes the form without a POST.
- Create errors surface inline as `E-mailadres bestaat al of ongeldig.` (the `useCreateUser` onError
path), which renders in the `UserForm` error slot.
- `UserForm`: name/email are trimmed into the payload and the selected `Rol` is carried through; a
password shorter than 8 shows `Minstens 8 tekens.` and blocks submission (the `password.length < 8`
guard); the submit button is disabled while `pending`.
- The role button promotes a worker to admin (`{ role: 'admin' }`) or demotes an admin to worker
(`{ role: 'worker' }`) via `POST /api/admin/users/:id/role`.
- Inline password reset: `Reset wachtwoord` reveals a password field with `Opslaan`/`Annuleer`;
`Opslaan` stays disabled until the new password is at least 8 chars, then POSTs `{ password }` to
`/api/admin/users/:id/password` and closes the field on success; `Annuleer` closes it without a POST.
- `Deactiveer` POSTs to `/api/admin/users/:id/deactivate` (shown on active peers) and `Heractiveer`
POSTs to `/api/admin/users/:id/reactivate` (shown on inactive peers).
- Every mutation (`create`/`setRole`/`resetPassword`/`deactivate`/`reactivate`) invalidates the
`['admin','users']` query so the list refetches and reflects the change.
- Lifecycle (`src/screens/Users.test.tsx`): create a worker via the form → it appears in the refreshed
list as `Werker` → promote it via `Maak admin` → the pill flips to `Beheerder` after the refetch →
reset its password inline (`Opslaan`) → the POST fires and the inline field closes.
## Activities + app/nav — admin client
Src covered (admin `apps/admin`): `src/screens/Activities.tsx`, `src/api/activities.ts`,
`src/App.tsx`, and `src/components/Sidebar.tsx` — all at 100% stmts/branch/funcs/lines. Three
`c8 ignore`s with reasons in `Activities.tsx`: the early-return guards in `handleAdd`, `handleSave`,
and `handleMove` are unreachable via the UI because the button that triggers each is disabled in
exactly that state (`Stap toevoegen` when `!addEnabled`, `Opslaan` when the name/types are empty,
the ↑/↓ move buttons at the list bounds).
Guaranteed behaviors:
- Activities renders the Dutch header (`Handelingen` / `Beheer handelingen per zooltype`), the
add-handeling card, and the current-steps list with a live count (`Huidige stappen (<n>)`).
- The insole-type toggles select/deselect a type (both the remove and re-add branches), reflected via
`aria-pressed`; a new handeling defaults to all three types (`Kurk`, `Berk`, `3D`).
- Creating a handeling POSTs `/api/activities` with the trimmed name and the selected `insole_types`
subset (a deselected type is dropped from the payload); `Stap toevoegen` is disabled while the name
is blank or every type is deselected.
- Editing a handeling opens an inline editor prefilled from the row and PUTs `/api/activities/:id` with
the updated name + `insole_types`; an activity stored with no insole types pre-selects all three in the
editor; `Annuleren` exits edit mode without issuing a PUT.
- Deleting a handeling confirms first (`window.confirm`); DELETE `/api/activities/:id` fires only when
confirmed, and is skipped when cancelled.
- Reorder: ↑/↓ move a row and PUT `/api/activities/reorder` with the swapped id order; ↑ is disabled on
the first row and ↓ on the last.
- The list shows the loading placeholder (`Laden...`) while the query is pending and the empty state
(`Nog geen stappen. Voeg er een toe hierboven.`) when there are none; a query error falls back to the
same empty state (there is no dedicated error UI).
- `api/activities.ts` hooks hit the right path/method — `useActivities` → GET `/api/activities`;
`useCreateActivity` → POST; `useUpdateActivity` → PUT `/:id`; `useDeleteActivity` → DELETE `/:id`;
`useReorderActivities` → PUT `/reorder` — and every mutation invalidates the `['activities']` query on
success.
- App/nav: `App` gates on auth (no token → `Login`; a token → the authed shell with `Sidebar` + routed
screens). `Sidebar` renders the brand, the five nav links, and the signed-in email (hidden when
`useMe` has no data), and `Uitloggen` calls `signOut`.
- `Sidebar` highlights the active route via `nav-link-active` and, with `end` matching on `/`, keeps the
Live link inactive on any non-root route; clicking a nav link switches the rendered screen and moves
the highlight.
- Lifecycle (`src/screens/Activities.test.tsx`): create a handeling → edit its insole types down to a
single type → reorder it above another → delete it, with each step reflected in the refetched list;
the Sidebar test (`src/components/Sidebar.test.tsx`) covers navigation switching screens.
## Stopwatch + reconcile — worker client
Src covered (worker `apps/worker`): `src/screens/Stopwatch.tsx` and `src/lib/stopwatch.ts` — both at
100% stmts/branch/funcs/lines. Five `c8 ignore`s with reasons in `Stopwatch.tsx`: the
`paused_seconds ?? 0` coalesce in the reconcile adopt (the contract types `paused_seconds` as
`z.number().int()`, never null) and the four early-return guards in `handleSelectZool`, `handleStart`,
`handleStop`, and `handleDiscard` — each unreachable because the button that triggers it is
disabled or not rendered in exactly that state (and the `handleStart` guard also narrows the type).
Guaranteed behaviors:
- `formatTime` renders zero-padded `HH:MM:SS`, floors a negative total at `00:00:00`, and lets hours
exceed two digits; `elapsedSeconds` returns `(now − start − paused)` floored at 0 (paused time
excluded).
- The three sections (`Type zool` / `Type handeling` / `Aantal zolen`) and the Dutch Start button
render; the zool segmented control offers `Kurk` / `Berk` / `3D` with `Kurk` selected by default.
- Choosing a zooltype filters the handeling `<select>` to activities whose `insole_types` include that
type and clears any previously chosen handeling; picking the empty option (`Kies een handeling...`)
also clears the chosen handeling.
- Start is disabled until a handeling is chosen; starting fires `useStartSession` with
`{ activity_id, insole_type, pair_count }` from the current selection.
- Pair count: the stepper `+`/`−` buttons adjust the count with a floor of 1 (the `−` button is
disabled at 1); the free-typed field mirrors the raw text but only adopts a parsed value greater
than 0 (a non-positive or non-numeric entry keeps the last valid count).
- The stopwatch display is tappable: while idle with a handeling chosen it starts the session (idle
tap with no handeling is a no-op); while running it pauses via `usePauseSession`; while paused it
resumes via `useResumeSession` (folding the just-finished pause span into the local accumulator).
- On a successful start the screen adopts the returned session's running state (id, start time, cleared
pause); a paused server session is recovered into the paused UI (`Gepauzeerd — tik om te hervatten`).
- Stop (`Stop & Opslaan`) and discard both reset the timer to idle on success; a `409` (already closed
server-side) on either resets locally instead of leaving the timer stuck.
- Discard is a double-tap: the first `Annuleren` tap arms it (`Nogmaals tikken ter bevestiging`) and the
second confirms and fires `useDiscardSession`; the arm window lapses after 3s back to `Annuleren`, and
a pending arm timer is cleared both on reconcile-reset and on unmount.
- Reconcile against the active-sessions poll: while idle with a server-active session, the screen adopts
it (phone-died / resume-elsewhere); the running-vs-server check keys on session id.
- Lifecycle (`src/screens/Stopwatch.test.tsx`): running locally after a successful start, when the
active-sessions poll returns a list **without** the local session (only another worker's), the timer
resets to idle and the `Deze sessie is door de beheerder gestopt.` notice shows.
## Admin lib/api (fetch wrapper)
Src covered: `apps/admin/src/lib/api.ts` — 100% stmts/branch/funcs/lines. (Filled in Phase C —
this base fetch helper fell between the D-domains, which covered the per-resource `api/*.ts`.)
Guaranteed behaviors:
- `apiFetch` attaches `Authorization: Bearer <token>` when a token is stored, and omits it when not.
- It sets `Content-Type: application/json` when a body is present and none was set, and never
overrides an explicitly provided `Content-Type`.
- It throws `ApiError` (carrying the HTTP status) on any non-2xx response, and returns `undefined`
for an empty 2xx body (otherwise the parsed JSON).
- `signIn` posts to `/api/auth/sign-in/email`, stores the token from the `set-auth-token` header,
throws `ApiError` on rejected credentials, and throws `ApiError(500)` when the header is missing.