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.
16 KiB
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 /healthreturns{ status: 'ok' }.env.tsapplies 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 (PORTcoerced to a number).env.WEB_ORIGINSparsesCORS_ORIGINSinto a list, trimming whitespace and dropping empty entries; falls back to the default localhost origins whenCORS_ORIGINSis 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 whenNODE_ENV=production(reference activities still seeded).- The
migrate.ts/seed.tsCLI direct-run blocks arec8 ignored — unreachable under the test runner (exercised only viatsx 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-tokenheader); sign-in with a wrong password fails (status ≥ 400). - Public sign-up is rejected (
disableSignUp— admin creates users). GET /api/mereturns 401 without a token, and{ user: { id, email, name, role } }with a valid bearer; role reflects the user (worker/admin).GET /api/mefalls back to role'worker'when the session user has no role set.getSessionUsermapsid,role, andbannedfrom the session; defaultsroleto'worker'andbannedtofalsewhen unset; returnsnullwhen there is no session.isAdminis true only for theadminrole (false forworkerand fornull).adminGuardreturns 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 inadmin-users.test.ts.)- CORS: the allowed SPA origins (worker 5173, admin 5174) are echoed on the
access-control-allow-originheader, OPTIONS preflight is answered with the allowed methods, andset-auth-tokenis 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/activitiesrequires a session (401 without a token); any authenticated user — worker or admin — may read.- The list is ordered by
sort_orderthenname, and?insole_type=<type>filters to activities whoseinsole_typesinclude that type. POST/PUT/DELETE/PUT /reorderare admin-only: 401 without a token, 403 for a worker.- Create validates a non-empty (trimmed)
name(400 otherwise) and defaultsinsole_typesto all three (Kurk,Berk,3D) when omitted. - Create appends the new activity with a
sort_orderone higher than the current maximum. insole_typesround-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) setssort_orderfrom theidsarray position; it 400s on an empty array (zodmin(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,discardall require a session (401 without a token), as do the readsGET /api/sessionsandGET /api/sessions/active.POST /api/sessions/start: 200 creates anactive,source: 'app'session (nullend_time/duration_seconds); 400 on an invalid body and on a malformed-JSON body; 404 when theactivity_iddoes not exist.POST /:id/pause: 200 setspaused_aton 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 clearspaused_atand accumulatespaused_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 intopaused_seconds, and computesduration_seconds = wall-clock − paused_seconds(never negative); a never-paused stop keepspaused_seconds = 0andduration = wall-clock; 404 for a non-numeric/unknown id; 409 when already closed.POST /:id/discard: 200 marks the sessiondiscardedwith a nullduration_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/sessionsreturns the caller's sessions only, newest-first, joined to the activityname;GET /api/sessions/activereturns only that user'sactivesessions. toWorkSessionmaps every field to the API contract (incl.paused_at,paused_seconds,notes,created_at), threadsactivity_name/user_name/user_emailopts through (undefined when omitted), maps a nullinsoleType/durationSeconds/notesto null, and coalesces a nullishpaused_secondsto 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/exportreturns 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,includeWorkerdefaults to false) emits the exact legacy headerID,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 aWorkercolumn carryinguserName.- Every field is quoted;
quoteescapes 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/userNamerenders'', nullinsoleTyperendersKurk, nullpairCountrenders2, nulldurationSeconds/pausedSecondsrender00:00:00, and a nullendTimerenders an empty End Time cell. formatDurationrenders zero-paddedHH: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'scompletedsessions (active/discarded and other users' rows excluded); rows ordered by start time ascending; sets thetext/csvcontent-type and theattachment; filename="insole-production-report.csv"disposition header.- Lifecycle: covered by the worker-session anchor (
test/integration/worker-session-lifecycle.test.ts), whose final step assertsGET /api/exportreturns 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/sessionsreturns all users' sessions newest-first, each joined toactivity_name,user_name, anduser_email;GET /api/admin/sessions/activeis the same but filtered toactivesessions only.POST /api/admin/sessions(manual create): creates acompleted,source: 'manual'session withduration_seconds = span − paused_secondsand nullpaused_at; 400 on a schema-invalid body, onend_time < start_time, and whenpaused_secondsexceeds the worked span; 404 for an unknownuser_idand for an unknownactivity_id.PUT /api/admin/sessions/:id(edit): recomputesduration_secondswhenend_timeis present and sets it null whenend_timeis null (re-activating); 404 for a non-numeric or unknown id; 400 on a schema-invalid body, on an unparseablestart_time, onend_time < start_time, and whenpaused_secondsexceeds the span; 404 for an unknownactivity_id.POST /api/admin/sessions/:id/stop: completes another user's active session, folds an open pause span intopaused_seconds, clearspaused_at, and computesduration_secondsexcluding 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 sessiondiscardedwith anend_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 inGET /api/admin/report(totals,by_worker,by_activity,by_typeall matching the stored row) and inGET /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/reportandGET /api/admin/exportare gated: 401 without a token, 403 for a worker. - Query parsing (
parseReportQuery, shared by report + export): 400 whenfrom/tois missing, unparseable, or inverted (to < from); 400 on aninsole_typeoutsideKurk/Berk/3D; 400 on a non-numericactivity_id; the export route rejects the same bad queries with 400. - Report scope: only
completedsessions inside[from, to](bystart_time) are counted — active/discarded rows are excluded. - Filters narrow the result set:
user_id,insole_type, andactivity_ideach restrict the counted sessions (individually and combined). - Totals sum
worked_seconds,paused_seconds,pairs, andsessionsacross every matched row, and equal the sum of each breakdown. - Breakdowns
by_worker,by_activity, andby_typegroup correctly and are each sorted byworked_secondsdescending. - A completed session with a null
insole_typeis bucketed under"Onbekend"inby_type(worker and activity buckets stay real); a completed session with a nullduration_secondscontributes0worked seconds. - An empty range yields zero totals and empty
by_worker/by_activity/by_typearrays. - Admin export: emits an all-users CSV with the leading
Workercolumn; thetext/csvcontent-type and aContent-Dispositionfilename ofsolelog-report_<from>_<to>.csv(dates sliced toYYYY-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 everyby_worker,by_activity,by_typerow (values and descending order) → apply auser_idfilter → 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 ignored 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 (adminGuarddefense-in-depth). GET /api/admin/usersreturns every user ordered by name, each mapped bytoListItemto{ id, email, name, role, status, created_at }; a null DBrolefalls back toworkerand abannedrow surfaces asstatus: 'inactive'.POST /api/admin/userscreates 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/rolepromotes/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/deactivatebans the user, clears any live session rows, and returnsstatus: 'inactive'; 404 on an unknown id; refuses self-deactivation (400); allows deactivating a peer admin while always blocking self.POST /:id/reactivateclears the ban and returnsstatus: 'active'; 404 on an unknown id.POST /:id/passwordsets 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'ssetUserPasswordthrows.- 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.