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.
This commit is contained in:
Bas van Rossem
2026-07-22 13:03:04 +02:00
parent bdf65ae685
commit 31d13a8381
4 changed files with 325 additions and 6 deletions

View File

@@ -186,3 +186,39 @@ Guaranteed behaviors:
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.