Files
solelog/docs/reference/domain-test-checklist.md
Bas van Rossem 9ecbf952d3 test(api): Work-sessions — fill gaps + lifecycle test
Drives src/routes/sessions.ts and src/lib/work-session.ts to 100%
stmts/branch/funcs/lines (branch was 84.12% / 83.33%).

- sessions.test.ts: added error/edge branch tests — malformed-JSON start
  (400), non-numeric id on pause/resume/stop/discard (404), pause/resume of a
  no-longer-active session (409), discard missing (404) + already-closed (409),
  stop missing (404), and discard ownership scoping (user B → 404).
- work-session.test.ts: mapper unit tests — null insoleType, nullish
  pausedSeconds → 0, null durationSeconds/notes, full-field shape with
  activity/user opts, and opts defaulting to undefined.
- test/integration/worker-session-lifecycle.test.ts: the anchor lifecycle test
  (start→pause→resume→stop→history→export).

No bugs found — the code was correct. The two remaining uncovered branches were
the defensive `row.pausedSeconds ?? 0` guards in resume/stop; pausedSeconds is
NOT NULL DEFAULT 0 in the schema, so the null branch is unreachable via any
route. Annotated both with `/* c8 ignore next -- reason */` (comment only, no
behavior change).
2026-07-22 12:14:32 +02:00

130 lines
7.6 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.