Files
solelog/docs/reference/domain-test-checklist.md
Bas van Rossem bdf65ae685 test(api): Admin sessions — fill gaps + lifecycle test
Fills the session-part coverage gaps in src/routes/admin.ts (cross-user list,
active list, manual create/edit, admin stop/discard) and adds the admin↔worker
lifecycle test.

New route-level tests (apps/api/test/admin.test.ts):
- manual create: 400 on schema-invalid body; 400 when paused_seconds > span.
- edit: 404 non-numeric id; 400 schema-invalid body; 400 unparseable start_time;
  400 paused_seconds > span; 404 unknown activity.
- stop: 404 non-numeric/unknown id; folds an open pause into paused_seconds.
- discard: 404 non-numeric/unknown id; 409 when already closed.

Lifecycle (apps/api/test/integration/admin-worker-session.test.ts): worker
starts → admin stops via the admin endpoint → session lands in /api/admin/report
and /api/admin/export with matching totals (uses resetDb for exact counts).

admin.ts session parts now at 100% stmts/lines; every session-part branch
covered. The one remaining session-part branch (row.pausedSeconds ?? 0 in the
stop route) is c8-ignored — paused_seconds is NOT NULL DEFAULT 0, so the null
branch is DB-impossible. The remaining uncovered branches in admin.ts are the
report/export parts, owned by the Reports domain (B-A6).

No behavior-changing bugs found; all session-part code was already correct.
2026-07-22 12:41:52 +02:00

189 lines
12 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).