Files
solelog/docs/reference/domain-test-checklist.md
2026-07-23 14:07:14 +02:00

330 lines
21 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, 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 -- <reason> */`, not left silent.
<!-- 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).