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

31 KiB
Raw Permalink Blame History

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)

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:

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):

    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:

    "test:coverage": "vitest run --coverage",
  • Step 5: Run coverage in each workspace and confirm it produces a report

Run:

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
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:

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):

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
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:

# 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
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:

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
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
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
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.