Fill the remaining coverage gaps in src/routes/admin-users.ts and add the domain lifecycle test, bringing the file to 100% stmts/branch/funcs/lines. New tests (apps/api/test/admin-users.test.ts): - password route: 404 unknown id; 400 when better-auth setUserPassword throws (driven via a spy so the catch path is deterministic). - toListItem: a null DB role lists as 'worker' (nullish-coalescing fallback). - role route: 400 on an invalid role body; 404 on an unknown id. - reactivate route: 404 on an unknown id. Lifecycle (apps/api/test/integration/user-lifecycle.test.ts): admin creates a worker -> worker signs in and the bearer authorizes /api/me -> admin deactivates (session rows deleted, so the pre-issued token 401s and fresh sign-in is blocked) -> admin reactivates -> sign-in works again. The two last-admin guards (role + deactivate routes) are unreachable over HTTP (the self-demote / self-deactivate guards above already handle caller===target, so any caller reaching them is a distinct active admin and activeAdminsExcluding(target) >= 1). Annotated with /* c8 ignore */ + reasons rather than left as silent gaps; the guard logic is unit-tested via activeAdminsExcluding. No production behavior changed; no bugs found.
258 lines
16 KiB
Markdown
258 lines
16 KiB
Markdown
# 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).
|
||
|
||
## 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.
|
||
|
||
## Admin users
|
||
|
||
Src covered: `src/routes/admin-users.ts` — 100% stmts/branch/funcs/lines. The two last-admin
|
||
guards (in the role and deactivate routes) are `c8 ignore`d with reasons: they are unreachable
|
||
over HTTP because the self-demote / self-deactivate guards above already handle `caller === target`,
|
||
so any caller reaching them is a distinct active admin and `activeAdminsExcluding(target) >= 1`; the
|
||
guard logic itself is unit-tested through `activeAdminsExcluding`.
|
||
|
||
Guaranteed behaviors:
|
||
|
||
- Every `/api/admin/users*` route is gated: 401 without a token, 403 for a worker, and 403 for a
|
||
banned admin that still holds a valid token (`adminGuard` defense-in-depth).
|
||
- `GET /api/admin/users` returns every user ordered by name, each mapped by `toListItem` to
|
||
`{ id, email, name, role, status, created_at }`; a null DB `role` falls back to `worker` and a
|
||
`banned` row surfaces as `status: 'inactive'`.
|
||
- `POST /api/admin/users` creates a user who can then sign in; 409 on a duplicate email; 400 on a
|
||
schema-invalid body (incl. a password shorter than 8 chars); a mixed-case email is stored
|
||
lowercased and the created row is returned (no 500 on the re-select).
|
||
- `POST /:id/role` promotes/demotes and returns the updated row; 400 on an invalid role body; 404 on
|
||
an unknown id; refuses self-demotion (400); allows demoting a peer admin while the caller stays
|
||
admin (200).
|
||
- `activeAdminsExcluding` (unit) counts only active (non-banned) admins and honours the excluded id.
|
||
- `POST /:id/deactivate` bans the user, clears any live session rows, and returns
|
||
`status: 'inactive'`; 404 on an unknown id; refuses self-deactivation (400); allows deactivating a
|
||
peer admin while always blocking self.
|
||
- `POST /:id/reactivate` clears the ban and returns `status: 'active'`; 404 on an unknown id.
|
||
- `POST /:id/password` sets a new password (new works, old fails); 400 on a too-short password; 404
|
||
on an unknown id; surfaces a 400 (not a 500) when better-auth's `setUserPassword` throws.
|
||
- Lifecycle (`test/integration/user-lifecycle.test.ts`): admin creates a worker → worker signs in
|
||
and the bearer authorizes `/api/me` → admin deactivates → the session rows are deleted so the
|
||
pre-issued token no longer authorizes `/api/me` (401) **and** a fresh sign-in is blocked → admin
|
||
reactivates → the ban is cleared and sign-in works again.
|