Files
solelog/docs/reference/domain-test-checklist.md
Bas van Rossem 31d13a8381 test(api): Reports + admin export — fill gaps + lifecycle test
Drives the report/export parts of src/routes/admin.ts to 100%
stmts/branch/funcs/lines. Adds one lifecycle test plus gap tests for the
previously-uncovered branches; no production behavior changed.

Tests added (apps/api/test):
- report.test.ts: invalid insole_type / non-numeric activity_id → 400;
  activity filter narrows; null insole_type bucketed "Onbekend"; null
  duration counts as 0 worked; export 400 on missing/inverted range.
  Migrated the report + export describes to resetDb(beforeEach) so exact
  totals are order-independent (removed the activity_id isolation
  workaround in the all-users export test).
- integration/report-aggregation.test.ts: lifecycle — 2 workers × 2
  activities × 2 types → exact totals + every by_worker/by_activity/
  by_type row (values and worked-desc order) → user_id filter narrows.

Defensive annotations in admin.ts report loop (behavior unchanged):
- pausedSeconds/pairCount ?? 0 — NOT NULL columns, DB-impossible nulls.
- user_name/activity_name ?? 'Onbekend' — unreachable; userId FK cascades
  on delete and activityId FK is enforced (libsql enforces FKs), so every
  session always joins to a real user and activity.

No bugs found; the code was already correct.
2026-07-22 13:03:04 +02:00

14 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).

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 ignored 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.