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

12 KiB
Raw Blame History

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.

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 ignored — 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 ignored 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 ignored 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 ignores 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 ignored 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).