Files
solelog/docs/superpowers/plans/2026-07-22-per-domain-test-safety-net.md

567 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Per-domain Test Safety Net — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. This plan is also structured to run as a **Workflow** (one agent per domain in Phase B) — see "Execution vehicle" at the end.
**Goal:** Give every domain across `apps/api`, `apps/worker`, and `apps/admin` a measured, near-100% test safety net (gap-fill + one lifecycle test per domain), and log/triage any bugs found.
**Architecture:** Three-phase. **Phase A** lays the foundation you can't measure without: coverage tooling in all three workspaces and a per-test DB reset that isolates domain suites. **Phase B** sweeps each domain with the same repeatable procedure (measure → fill to near-100% → add one lifecycle test → record checklist → log bugs). **Phase C** synthesizes: aggregate coverage, finalize the living checklist doc, update tracking.
**Tech Stack:** Vitest 3 + `@vitest/coverage-v8` (v8 provider). API: Hono + better-auth + Drizzle/libsql, node env, real file DB, serial. Clients: Vite + React + Testing Library, jsdom env. Yarn 4 workspaces. oxlint + oxfmt.
## Global Constraints
- **Coverage target:** near-100% per domain. Uncovered lines are either tested or annotated `/* c8 ignore next -- <reason> */` with a real reason. **No silent gaps.** No CI threshold *gate* (enforced by review for now).
- **Vitest version:** all three workspaces are on `vitest@^3.0.0` → coverage package must be `@vitest/coverage-v8@^3`.
- **API harness invariant:** migration runs once at top level in `apps/api/test/setup.ts` *before* the libsql file is opened (Windows EBUSY constraint) — do not move migration into `beforeAll`/`beforeEach`. Per-test isolation is done by **deleting rows**, never by re-migrating or unlinking the file.
- **Test style (API):** `createApp()` per test; auth via `authToken(app, email, role)` → `bearer(token)`; activities via `seedActivity(name, insoleTypes?)`; deterministic timing by back-dating rows with `db.update(workSessions).set({ startTime: new Date(Date.now() - N) })`. Mirror `apps/api/test/sessions.test.ts`.
- **Test style (clients):** Testing Library + jsdom; mirror the existing `*.test.tsx` in the same workspace for how query hooks/fetch are mocked. Do not invent a new mocking approach.
- **Bug handling:** every bug found → a Plane work item (child of the epic from Task 3). Trivial/safe fix → apply inline in the same commit as the test that exposes it, and note it in the commit body. Risky/behavior-changing → leave a **failing or `.skip`-with-reason test**, file the Plane ticket, do **not** change behavior. Flag which in the domain's report.
- **Commits:** one commit per domain task (Phase B) and per foundation task (Phase A). oxfmt changed files before committing; keep `npx oxlint` clean.
- **Tracking:** SoleLog in **Plane** (project id `1956d3d3-15ed-4850-93d8-96d5fb17f213`), not Jira. Docs live in `docs/`. Never touch `.env*`, `docker-compose*`, `.mcp.json`, `CLAUDE.local.md`.
---
## Phase A — Foundation
### Task 1: Coverage tooling in all three workspaces
**Files:**
- Modify: `apps/api/package.json`, `apps/api/vitest.config.ts`
- Modify: `apps/worker/package.json`, `apps/worker/vitest.config.ts`
- Modify: `apps/admin/package.json`, `apps/admin/vitest.config.ts`
**Interfaces:**
- Produces: a `test:coverage` script in each workspace that emits a text + json-summary report. Phase B tasks consume `yarn workspace <name> test:coverage` to find uncovered lines.
- [ ] **Step 1: Install the matching coverage provider (all three workspaces)**
```bash
yarn workspace @solelog/api add -D @vitest/coverage-v8@^3
yarn workspace @solelog/worker add -D @vitest/coverage-v8@^3
yarn workspace @solelog/admin add -D @vitest/coverage-v8@^3
```
- [ ] **Step 2: Add coverage config to the API vitest config**
Replace `apps/api/vitest.config.ts` with:
```ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
setupFiles: ['./test/setup.ts'],
fileParallelism: false,
coverage: {
provider: 'v8',
all: true,
reporter: ['text', 'json-summary'],
include: ['src/**'],
// Generated / non-logic files: excluded with intent (not silent gaps).
exclude: ['src/db/schema.ts', 'src/index.ts', '**/*.d.ts'],
},
},
});
```
- [ ] **Step 3: Add coverage config to the worker AND admin vitest configs**
In BOTH `apps/worker/vitest.config.ts` and `apps/admin/vitest.config.ts`, add a `coverage` block inside `test` (keep the existing `environment`/`globals`/`setupFiles`/`plugins`):
```ts
coverage: {
provider: 'v8',
all: true,
reporter: ['text', 'json-summary'],
include: ['src/**'],
exclude: ['src/main.tsx', 'src/vite-env.d.ts', 'src/test/**', '**/*.d.ts'],
},
```
- [ ] **Step 4: Add the `test:coverage` script to all three `package.json`**
In each of `apps/api`, `apps/worker`, `apps/admin` package.json `scripts`, add:
```json
"test:coverage": "vitest run --coverage",
```
- [ ] **Step 5: Run coverage in each workspace and confirm it produces a report**
Run:
```bash
yarn workspace @solelog/api test:coverage
yarn workspace @solelog/worker test:coverage
yarn workspace @solelog/admin test:coverage
```
Expected: all suites still green; a coverage table prints; `coverage/coverage-summary.json` is written in each workspace. Note the per-file % — this is the Phase B work-list.
- [ ] **Step 6: Ignore the coverage output dir**
Confirm `coverage/` is git-ignored (add `coverage/` to root `.gitignore` if not already). Never commit the report.
- [ ] **Step 7: Commit**
```bash
git add apps/*/package.json apps/*/vitest.config.ts .gitignore yarn.lock
git commit -m "test: add @vitest/coverage-v8 + test:coverage to all workspaces"
```
---
### Task 2: Per-test DB isolation for the API suite
**Files:**
- Modify: `apps/api/test/helpers.ts` (add `resetDb`)
- Modify: `apps/api/test/sessions.test.ts` (adopt it as proof — removes reliance on accumulated state)
**Interfaces:**
- Produces: `export async function resetDb(): Promise<void>` — deletes all rows from `workSessions`, `activities`, and the better-auth tables (`session`, `account`, `user`, `verification`), leaving the migrated schema intact. Phase B API domain tasks call it in `beforeEach`.
- [ ] **Step 1: Write a failing test for `resetDb`**
Create `apps/api/test/reset-db.test.ts`:
```ts
import { describe, it, expect, beforeEach } from 'vitest';
import { db } from '../src/db/client';
import { workSessions, user } from '../src/db/schema';
import { resetDb, seedActivity, authToken } from './helpers';
import { createApp } from '../src/app';
describe('resetDb', () => {
beforeEach(resetDb);
it('clears domain and auth rows so counts start from zero', async () => {
const app = createApp();
await authToken(app, 'reset-a@example.com');
await seedActivity('Zagen');
expect((await db.select().from(user)).length).toBeGreaterThan(0);
await resetDb();
expect(await db.select().from(user)).toHaveLength(0);
expect(await db.select().from(workSessions)).toHaveLength(0);
});
});
```
- [ ] **Step 2: Run it to verify it fails**
Run: `yarn workspace @solelog/api test reset-db`
Expected: FAIL — `resetDb` is not exported from `./helpers`.
- [ ] **Step 3: Implement `resetDb` in `helpers.ts`**
Add these imports and function to `apps/api/test/helpers.ts` (extend the existing schema import):
```ts
import { activities, session, account, user, verification, workSessions } from '../src/db/schema';
// Wipe all data rows (schema stays; migration already ran once in setup.ts).
// Order respects FKs: work sessions → sessions/accounts → users; activities last.
export async function resetDb(): Promise<void> {
await db.delete(workSessions);
await db.delete(session);
await db.delete(account);
await db.delete(verification);
await db.delete(user);
await db.delete(activities);
}
```
- [ ] **Step 4: Run it to verify it passes**
Run: `yarn workspace @solelog/api test reset-db`
Expected: PASS.
- [ ] **Step 5: Adopt `resetDb` in `sessions.test.ts` as proof of isolation**
At the top of `apps/api/test/sessions.test.ts`, add `beforeEach` to the imports and add `beforeEach(resetDb)` inside each `describe` block (`'session lifecycle'` and `'session reads'`). Import `resetDb` from `./helpers`.
- [ ] **Step 6: Run the full API suite**
Run: `yarn workspace @solelog/api test`
Expected: all green (103+ tests). If any test now fails because it depended on another test's rows, that IS the fragility we're removing — fix that test to seed its own data.
- [ ] **Step 7: Commit**
```bash
git add apps/api/test/helpers.ts apps/api/test/reset-db.test.ts apps/api/test/sessions.test.ts
git commit -m "test(api): add resetDb helper + per-test isolation (proof: sessions suite)"
```
---
### Task 3: Tracking scaffold + living checklist doc
**Files:**
- Create: `docs/reference/domain-test-checklist.md` (the living "guaranteed behaviors" doc)
- Plane: create the epic + one child per domain
**Interfaces:**
- Produces: Plane epic id (call it `EPIC`) and per-domain child ids; the checklist doc that Phase B appends to.
- [ ] **Step 1: Create the Plane epic and per-domain child work items**
Using the `plane` MCP tools (project id `1956d3d3-15ed-4850-93d8-96d5fb17f213`):
- `create_work_item(name: "Testing & hardening — per-domain safety net")` → this is `EPIC`.
- For each domain in Phase B (18 total), `create_work_item(name: "Tests — <domain>", parent: EPIC)`.
- [ ] **Step 2: Create the checklist doc skeleton**
Create `docs/reference/domain-test-checklist.md`:
```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).
## Coverage baseline (Task 1)
| Workspace | Statements | Branches | Functions | Lines |
| --------- | ---------- | -------- | --------- | ----- |
| api | TBD | TBD | TBD | TBD |
| worker | TBD | TBD | TBD | TBD |
| admin | TBD | TBD | TBD | TBD |
<!-- Phase B appends one "## <domain>" section per domain below. -->
```
Fill the baseline table now from the Task 1 Step 5 `coverage-summary.json` totals.
- [ ] **Step 3: Commit**
```bash
git add docs/reference/domain-test-checklist.md
git commit -m "docs: domain-test-checklist skeleton + coverage baseline"
```
---
## Phase B — Per-domain sweep
**Domain Procedure (every Phase B task follows these six steps — the mechanics are identical, only the inputs in each task differ):**
1. **Measure.** `yarn workspace <ws> test:coverage` and read the uncovered lines for this domain's src files (listed per task). That uncovered list + the task's behavior checklist = the exact work-list.
2. **Write one failing test per gap/behavior.** For each unchecked behavior and each uncovered branch: add a test (unit or route-level) mirroring the workspace's existing style. Run it, confirm it fails for the right reason (`.only` while iterating).
3. **Add the domain's one lifecycle test** (specified per task).
4. **Make green.** If a test fails because the code is *wrong* (not because the test is): it's a **bug** → Global Constraints "Bug handling". Otherwise the code is already correct and the test just locks it in.
5. **Verify near-100%.** Re-run coverage for this domain's files. Remaining uncovered lines must be either tested or `/* c8 ignore next -- <reason> */`. Run `npx oxfmt` on changed files; `npx oxlint` clean.
6. **Record + commit.** Append a `## <domain>` section to `docs/reference/domain-test-checklist.md` with the guaranteed-behavior bullets. Commit test+doc (+ inline trivial fix) as one commit. Update the domain's Plane child to Done (note bugs found).
**Anchor lifecycle test (fully-runnable reference — sets the quality bar for all lifecycle tests):**
Create `apps/api/test/integration/worker-session-lifecycle.test.ts`:
```ts
import { describe, it, expect, beforeEach } from 'vitest';
import { eq } from 'drizzle-orm';
import { createApp } from '../../src/app';
import { db } from '../../src/db/client';
import { workSessions } from '../../src/db/schema';
import { authToken, bearer, seedActivity, resetDb } from '../helpers';
describe('worker session lifecycle (start→pause→resume→stop→history→export)', () => {
beforeEach(resetDb);
it('excludes paused time, shows in history, and exports a CSV row', async () => {
const app = createApp();
const token = await authToken(app, 'lifecycle@example.com');
const activityId = await seedActivity('Frezen', ['Kurk']);
// start
const startRes = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 3 }),
});
expect(startRes.status).toBe(200);
const started = await startRes.json();
// pause, then backdate the pause start by 4s so resume accumulates a real span
await app.request(`/api/sessions/${started.id}/pause`, { method: 'POST', headers: bearer(token) });
await db
.update(workSessions)
.set({ pausedAt: new Date(Date.now() - 4000) })
.where(eq(workSessions.id, started.id));
const resumeRes = await app.request(`/api/sessions/${started.id}/resume`, {
method: 'POST',
headers: bearer(token),
});
expect((await resumeRes.json()).paused_seconds).toBeGreaterThan(0);
// backdate the start by 10s so worked-time is deterministic-ish, then stop
await db
.update(workSessions)
.set({ startTime: new Date(Date.now() - 10000) })
.where(eq(workSessions.id, started.id));
const stopRes = await app.request(`/api/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(token),
});
const stopped = await stopRes.json();
expect(stopped.status).toBe('completed');
expect(stopped.paused_seconds).toBeGreaterThan(0);
expect(stopped.duration_seconds).toBeLessThan(10); // paused time excluded
const wall = Math.round((Date.parse(stopped.end_time) - Date.parse(stopped.start_time)) / 1000);
expect(stopped.duration_seconds + stopped.paused_seconds).toBe(wall);
// history shows it
const hist = await (await app.request('/api/sessions', { headers: bearer(token) })).json();
expect(hist).toHaveLength(1);
expect(hist[0].id).toBe(started.id);
expect(hist[0].activity_name).toBe('Frezen');
// export contains the completed row
const csvRes = await app.request('/api/export', { headers: bearer(token) });
expect(csvRes.status).toBe(200);
const csv = await csvRes.text();
expect(csv).toContain('Frezen');
expect(csv.trim().split('\n').length).toBe(2); // header + 1 row
});
});
```
> For every OTHER lifecycle test below, the task gives the exact endpoint chain and assertions; write it in this same shape, mirroring the referenced existing test file for request/mock mechanics. Put API integration tests in `apps/api/test/integration/`, client ones next to the screen they exercise.
---
### API domains
Coverage command for all API tasks: `yarn workspace @solelog/api test:coverage`.
### Task B-A0: Infra (health, db, schema, seed, env, migrate)
**Files:** src `src/routes/health.ts`, `src/db/client.ts`, `src/db/migrate.ts`, `src/env.ts`; tests `apps/api/test/{health,db,schema,seed}.test.ts`.
**Behavior checklist:**
- `/health` returns `{ status: 'ok' }` (exists).
- `env.ts` applies each dev default when the var is unset, and reads the override when set.
- `runMigrations()` is idempotent (running twice does not throw).
- seed creates the dev admin + worker with the expected roles (exists).
**Lifecycle test:** none (infra). Focus: fill `env.ts` branch coverage (each default), add an idempotent-migration test.
**Acceptance:** near-100% on `env.ts`, `health.ts`; `db/client.ts`/`migrate.ts` covered or `c8 ignore` on unreachable error branches.
### Task B-A1: Auth & `/me`
**Files:** src `src/auth.ts`, `src/routes/me.ts`, `src/lib/require-user.ts`, CORS in `src/app.ts`; tests `apps/api/test/{auth,me,cors}.test.ts`.
**Behavior checklist:**
- sign-in returns a bearer token; sign-in with wrong password fails.
- `/api/me` 401 without token; returns `{ user: { id, email, name, role } }` with token.
- `getSessionUser` maps role (default `'worker'`) and `banned`.
- `adminGuard`: 401 no session, 403 banned or non-admin, passes for active admin.
- `isAdmin` true only for role `admin`.
- CORS: allowed origin echoed; preflight OPTIONS handled.
**Lifecycle test:** `apps/api/test/integration/auth-lifecycle.test.ts` — create worker → sign in → call `/api/me` (200, role worker) → call an admin route (403) → assert bearer works across requests.
**Acceptance:** near-100% on `require-user.ts`, `me.ts`; `auth.ts` config covered by the round-trips (ignore untestable option branches with a reason).
### Task B-A2: Activities
**Files:** src `src/routes/activities.ts`; test `apps/api/test/activities.test.ts`.
**Behavior checklist:**
- GET list (public/authed per current behavior) ordered by `sort_order`.
- POST/PUT/DELETE are admin-only (worker → 403; no token → 401).
- create validates name non-empty (400) and defaults `insole_types` to all three.
- reorder endpoint sets `sort_order` from the `ids` array; rejects empty array (400).
- `insole_types` round-trips as a subset.
**Lifecycle test:** `activities-lifecycle` — admin creates 3 activities → reorders them → worker GET reflects new order → admin deletes one → gone from list.
**Acceptance:** near-100% on `activities.ts`.
### Task B-A3: Work-sessions
**Files:** src `src/routes/sessions.ts`, `src/lib/work-session.ts`; test `apps/api/test/{sessions,work-session}.test.ts` (already deep — expect mostly gap-fill + the anchor lifecycle test).
**Behavior checklist:** start (200/400/404/401), pause (200/404/409 double), resume (200/409 running/409 not-paused), stop (duration excludes paused; 409 double), discard (200; duration null), ownership scoping (B can't touch A → 404), reads (history newest-first + activity join; active-only; per-user scoping), `toWorkSession` mapper shapes every field incl. `paused_at`.
**Lifecycle test:** the anchor `worker-session-lifecycle.test.ts` above (create it here).
**Acceptance:** near-100% on `sessions.ts`, `work-session.ts`.
### Task B-A4: Worker export / CSV
**Files:** src `src/lib/csv.ts` (+ `/api/export` in `sessions.ts`); tests `apps/api/test/{csv,export}.test.ts`.
**Behavior checklist:**
- `buildSessionsCsv(rows, { includeWorker:false })` byte-shape unchanged (worker export).
- `includeWorker:true` prepends a Worker column.
- quoting escapes commas/quotes/newlines in fields.
- `formatDuration` renders H:MM:SS (or the current format) incl. zero and paused columns.
- `/api/export` 401 without token; only `completed` rows; ordered by start time; filename header.
**Lifecycle test:** covered by the worker-session anchor (export step). Add a CSV-escaping edge unit test (activity name with a comma + quote).
**Acceptance:** near-100% on `csv.ts`.
### Task B-A5: Admin sessions
**Files:** src `src/routes/admin.ts` (session parts: cross-user list, active, manual create/edit, stop, discard, users roster); test `apps/api/test/admin.test.ts`.
**Behavior checklist:**
- every `/api/admin/*` route: 401 no token, 403 worker.
- GET cross-user sessions includes `user_name`/`user_email`; active-only variant.
- manual create (`CreateManualSessionInput`) computes duration from start/end; validation 400.
- edit (`AdminUpdateSessionInput`) updates fields incl. status; 404 missing.
- admin stop/discard of a running session; 409 when already closed.
- users roster returns id/name (used by pickers).
**Lifecycle test:** `admin-worker-session.test.ts` — worker starts a session → admin stops it via the admin endpoint → session appears in `/api/admin/report` and `/api/admin/export` with correct totals. (Match request/response shapes to `admin.test.ts` and `report.test.ts`.)
**Acceptance:** near-100% on the session parts of `admin.ts`.
### Task B-A6: Reports + admin export
**Files:** src `src/routes/admin.ts` (report/export parts: `parseReportQuery`, `buildSessionFilters`, `/api/admin/report`, `/api/admin/export`); tests `apps/api/test/{report,export}.test.ts`.
**Behavior checklist:**
- report: completed-only; date-range + user/type/activity filters; totals (worked/paused/pairs/sessions); by_worker/by_activity/by_type sorted worked desc; empty range → zeros; null insole_type bucketed `"Onbekend"`.
- admin export: all-users CSV with Worker column; filename `solelog-report_<from>_<to>.csv`; honors the same filters.
- 401/403 gating.
**Lifecycle test:** `report-aggregation.test.ts` — seed known completed sessions for 2 workers × 2 activities → assert every total and each breakdown row exactly → apply a worker filter → assert it narrows.
**Acceptance:** near-100% on the report/export parts of `admin.ts`.
### Task B-A7: Admin users
**Files:** src `src/routes/admin-users.ts`; test `apps/api/test/admin-users.test.ts` (already strong — gap-fill + lifecycle).
**Behavior checklist:** list (role+status, ordered by name); create (409 dup; mixed-case email re-select; 400 invalid; password floor 8); set role (self-demote guard, last-admin guard, 404); deactivate (bans + deletes sessions; self + last-admin guards; 404); reactivate (clears ban); reset password (400 invalid; new works / old fails); `activeAdminsExcluding` unit; all routes gated (401/403 incl. banned admin).
**Lifecycle test:** `user-lifecycle.test.ts` — admin creates worker → worker signs in (token works) → admin deactivates → worker sign-in blocked **and** the pre-issued token no longer authorizes `/api/me` (session rows deleted) → admin reactivates → worker signs in again.
**Acceptance:** near-100% on `admin-users.ts`.
---
### Worker client domains
Coverage command: `yarn workspace @solelog/worker test:coverage`. Mirror existing `*.test.tsx` for mocking.
### Task B-W1: Auth (context/storage/login)
**Files:** src `src/auth/AuthContext.tsx`, `src/lib/auth-storage.ts`, `src/screens/Login.tsx`; tests alongside.
**Behavior checklist:** storage set/get/clear token; AuthContext exposes user + login/logout, restores token on mount, clears on 401; Login submits credentials, shows error on failure, disables submit while pending.
**Lifecycle test:** `Login` → successful submit stores token and flips context to authenticated (app renders the tabbed shell).
**Acceptance:** near-100% on `auth-storage.ts`, `AuthContext.tsx`, `Login.tsx`.
### Task B-W2: Stopwatch + reconcile
**Files:** src `src/screens/Stopwatch.tsx`, `src/lib/stopwatch.ts`; tests alongside (`Stopwatch.test.tsx` exists — mirror its hook mocking).
**Behavior checklist:** zool select filters handelingen and clears chosen activity; pair-count stepper floor 1 + text entry; start disabled until an activity is chosen; tap display starts/pauses/resumes; discard double-tap arm/confirm window; adopts a server-active session when idle; 409 on stop/discard resets the timer; `elapsedSeconds`/`formatTime` math incl. paused accumulator.
**Lifecycle test (reconcile):** running locally, the active-sessions poll returns a list **without** the local session → the timer resets and the "door de beheerder gestopt" notice shows. (Drive by mocking `useActiveSessions` return across renders, as `Stopwatch.test.tsx` already does.)
**Acceptance:** near-100% on `stopwatch.ts`, `Stopwatch.tsx`.
### Task B-W3: History
**Files:** src `src/screens/History.tsx`, `src/lib/export.ts`; tests alongside.
**Behavior checklist:** renders sessions newest-first with activity name + duration; shows the paused line when `paused_seconds>0`; empty state; export triggers a download (blob/object-URL path in `export.ts`); loading/error states.
**Lifecycle test:** list renders a completed session with a paused line, then the export action calls the download path.
**Acceptance:** near-100% on `History.tsx`, `export.ts`.
### Task B-W4: Account
**Files:** src `src/screens/Account.tsx`; test alongside.
**Behavior checklist:** shows the signed-in user; logout clears auth and returns to Login.
**Lifecycle test:** click logout → context becomes unauthenticated → Login shows.
**Acceptance:** near-100% on `Account.tsx`.
### Task B-W5: lib/api + app/nav
**Files:** src `src/lib/api.ts`, `src/App.tsx`, `src/components/TabBar.tsx`, `src/api/*.ts`; tests alongside.
**Behavior checklist:** `apiFetch` attaches bearer, parses JSON, throws `ApiError` with status on non-2xx, handles empty body; `App` routes between tabs by auth state; TabBar switches active screen; each `api/*` hook hits the right path/method.
**Lifecycle test:** unauthenticated `App` shows Login; authenticated shows Stopwatch with the tab bar.
**Acceptance:** near-100% on `api.ts`, `App.tsx`, `TabBar.tsx`, `api/*.ts`.
---
### Admin client domains
Coverage command: `yarn workspace @solelog/admin test:coverage`. Mirror existing `*.test.tsx`.
### Task B-D1: Auth (admin-only gate)
**Files:** src `src/auth/AuthContext.tsx`, Login; tests alongside.
**Behavior checklist:** login as admin succeeds; login as **worker is rejected** (admin-only gate via `role` on `/api/me`); token persisted/restored; logout clears.
**Lifecycle test:** worker credentials → rejected with the not-authorized message; admin credentials → shell renders.
**Acceptance:** near-100% on admin `AuthContext.tsx`, `Login`.
### Task B-D2: Live view
**Files:** src `src/screens/Live.tsx`, `src/lib/elapsed.ts`; tests alongside.
**Behavior checklist:** renders active sessions with worker + activity + running elapsed; auto-refresh interval wired; paused card state; empty state; `elapsed` math.
**Lifecycle test:** active-sessions data renders cards; a paused session shows the paused treatment.
**Acceptance:** near-100% on `Live.tsx`, `elapsed.ts`.
### Task B-D3: Sessions + SessionForm
**Files:** src `src/screens/Sessions.tsx`, `src/components/SessionForm.tsx`; tests alongside.
**Behavior checklist:** list + status filter; create/edit form validation (times, pair count, activity); row actions stop/discard; disabled/pending states; error surfacing.
**Lifecycle test:** open create form → fill valid manual session → submit → appears in the list; edit it → change persists.
**Acceptance:** near-100% on `Sessions.tsx`, `SessionForm.tsx`.
### Task B-D4: Reports + date-range
**Files:** src `src/screens/Reports.tsx`, `src/api/reports.ts`, `src/lib/date-range.ts`; tests alongside.
**Behavior checklist:** period presets (week/month/all) map to correct ISO ranges; filters change the query; totals + three breakdown tables render; export button triggers download; `filtersToQuery`/date helpers unit-covered; empty state.
**Lifecycle test:** pick "Deze maand" + a worker filter → the report query reflects both and tables render the returned rows.
**Acceptance:** near-100% on `Reports.tsx`, `reports.ts`, `date-range.ts`.
### Task B-D5: Users
**Files:** src `src/screens/Users.tsx`, `src/components/UserForm.tsx`, `src/api/users.ts`; tests alongside.
**Behavior checklist:** list with role/status pills; own-row "jij" badge hides role/deactivate; create form validation (email, password min 8); role toggle; inline password reset; deactivate/reactivate; error surfacing (e.g. last-admin 400).
**Lifecycle test:** create a worker via the form → appears in the list → toggle to admin → reflected; reset password inline succeeds.
**Acceptance:** near-100% on `Users.tsx`, `UserForm.tsx`, `users.ts`.
### Task B-D6: Activities + app/nav
**Files:** src `src/screens/Activities.tsx`, `src/api/activities.ts`, `src/App.tsx`, `src/components/Sidebar.tsx`; tests alongside.
**Behavior checklist:** handelingen CRUD (create/edit/delete) with insole-type selection; up/down reorder; Sidebar nav highlights active route + routes render; empty/error states.
**Lifecycle test:** create an activity → edit its insole types → reorder → delete; Sidebar navigation switches screens.
**Acceptance:** near-100% on `Activities.tsx`, admin `api/activities.ts`, `App.tsx`, `Sidebar.tsx`.
---
## Phase C — Synthesis
### Task C1: Aggregate, finalize, and record
**Files:** `docs/reference/domain-test-checklist.md`, `docs/roadmap.md`, `docs/sessions/2026-07-22-per-domain-test-safety-net.md`.
- [ ] **Step 1: Re-run coverage in all three workspaces and fill the final table**
```bash
yarn workspace @solelog/api test:coverage
yarn workspace @solelog/worker test:coverage
yarn workspace @solelog/admin test:coverage
```
Update the coverage table in `domain-test-checklist.md` with final numbers. Every domain section present. Any file below near-100% must name why (with its `c8 ignore` reasons).
- [ ] **Step 2: Full green + lint sweep**
```bash
yarn workspace @solelog/api test && yarn workspace @solelog/worker test && yarn workspace @solelog/admin test
yarn workspace @solelog/api typecheck && yarn workspace @solelog/worker typecheck && yarn workspace @solelog/admin typecheck
npx oxlint
```
Expected: all green, typechecks clean, oxlint exit 0.
- [ ] **Step 3: Write the session log**
Create `docs/sessions/2026-07-22-per-domain-test-safety-net.md`: goal, per-domain outcomes, coverage before/after, and the **bug list** (each with its Plane id + inline-fixed vs deferred).
- [ ] **Step 4: Roadmap note**
Add a short "Testing & hardening" note to `docs/roadmap.md` (near Phase 5) recording the per-domain safety net + coverage baseline.
- [ ] **Step 5: Close tracking**
Set the Plane epic (Task 3 `EPIC`) to Done once all domain children are Done. Leave deferred-bug tickets open.
- [ ] **Step 6: Commit**
```bash
git add docs/
git commit -m "docs: finalize domain-test-checklist + session log + roadmap (test safety net)"
```
---
## Execution vehicle
Phase A is sequential (tooling → harness → tracking) — run inline or as three serial workflow steps. **Phase B is the fan-out:** each domain task is independent (touches its own test files), so run them as a Workflow — one agent per domain following the Domain Procedure, each returning `{ domain, coverageBefore, coverageAfter, bugs: [{summary, severity, fixed|deferred, planId}], newTests }`. API tasks that add integration files under `apps/api/test/integration/` share no source with client tasks, so all 18 can pipeline; the only shared file is `docs/reference/domain-test-checklist.md`, so either (a) have each agent return its checklist section as data and append them in Phase C, or (b) run with worktree isolation. Phase C synthesizes: aggregate coverage, write the doc/session log, flip Plane. Then independently verify (git log + full suites + coverage + typechecks + oxlint) before reporting.
## Self-review notes
- Spec §1 coverage tooling → Task 1. §2 harness → Task 2. §3 domain map (7 API + 5 worker + 6 admin = 18) → Tasks B-A0..A7 (8, incl. infra), B-W1..W5 (5), B-D1..D6 (6). §4 lifecycle tests → one per domain (anchor fully coded; others specified). §5 Plane → Task 3 + per-task close. §6 deliverables → Phase C. §7 workflow → Execution vehicle.
- Near-100% target enforced per-task (Acceptance) with `c8 ignore`-with-reason as the only escape.
- Note: API infra is split out as B-A0 so the "7 API domains" in the spec map to the seven feature routers; B-A0 covers non-router infra that would otherwise be an unmeasured gap.