Drive the worker Auth domain to 100% coverage: - AuthContext.tsx: 91.3/80 -> 100/100 (signIn body, mount-restore branches, useAuth guard) - Login.tsx: 78/50 -> 100/100 (handleSubmit success/error/pending, submit disable) - auth-storage.ts: already 100 (no change) New Login.test.tsx adds the domain lifecycle test: from the unauthenticated app, a successful login stores the token and flips the context to the authenticated tabbed shell. Extended AuthContext.test.tsx covers signIn delegation, mount restore (token present/absent), and the out-of-provider throw. No bugs found; code was already correct. Note: the design checklist's 'clears on 401' worker behavior is not implemented (no global 401 interceptor) and is left as-is (missing feature, not a defect).
18 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.
Auth (context/storage/login) — worker client
Src covered (worker apps/worker): src/auth/AuthContext.tsx, src/lib/auth-storage.ts, and
src/screens/Login.tsx — all at 100% stmts/branch/funcs/lines. No c8 ignores needed.
Guaranteed behaviors:
auth-storage:setTokenpersists the bearer,getTokenreads it back,clearTokenremoves it (getTokenreturnsnullwhen nothing is stored) — all vialocalStorage.AuthContextstarts unauthenticated when no token is stored and restores the authenticated state on mount when a token is already present (getToken() !== null).AuthContext.signIndelegates to the apisignIn(email, password)and, on success, flips the context to authenticated (isAuthed→ true).AuthContext.signOutresets the router path to/(so the next authed mount lands on Stopwatch, not Account), clears the stored token, and flips the context to unauthenticated.useAuththrows"useAuth must be used within an AuthProvider"when called outside the provider.Loginrenders the Dutch form (E-mailadres / Wachtwoord / Inloggen) and submits the typed credentials toPOST /api/auth/sign-in/email, storing the returned bearer token.Loginshows the"Inloggen mislukt"error (and stores no token) when sign-in fails, and disables the submit button while the request is pending, re-enabling it once it settles.- Lifecycle (
src/screens/Login.test.tsx): from the unauthenticated app, a successful login stores the token and flips the context so the tabbed authenticated shell (Stopwatch + TabBar) renders.
Note: the design checklist mentions "clears on 401" for the worker auth context — this is not implemented in the current code (there is no global 401 interceptor;
apiFetchthrowsApiErrorand callers surface it via React Query). Left as-is (missing feature, not a defect); not tested and not filed as a bug.