Drives src/routes/sessions.ts and src/lib/work-session.ts to 100% stmts/branch/funcs/lines (branch was 84.12% / 83.33%). - sessions.test.ts: added error/edge branch tests — malformed-JSON start (400), non-numeric id on pause/resume/stop/discard (404), pause/resume of a no-longer-active session (409), discard missing (404) + already-closed (409), stop missing (404), and discard ownership scoping (user B → 404). - work-session.test.ts: mapper unit tests — null insoleType, nullish pausedSeconds → 0, null durationSeconds/notes, full-field shape with activity/user opts, and opts defaulting to undefined. - test/integration/worker-session-lifecycle.test.ts: the anchor lifecycle test (start→pause→resume→stop→history→export). No bugs found — the code was correct. The two remaining uncovered branches were the defensive `row.pausedSeconds ?? 0` guards in resume/stop; pausedSeconds is NOT NULL DEFAULT 0 in the schema, so the null branch is unreachable via any route. Annotated both with `/* c8 ignore next -- reason */` (comment only, no behavior change).
130 lines
7.6 KiB
Markdown
130 lines
7.6 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.
|