docs(plan): per-domain test safety net implementation plan
This commit is contained in:
566
docs/superpowers/plans/2026-07-22-per-domain-test-safety-net.md
Normal file
566
docs/superpowers/plans/2026-07-22-per-domain-test-safety-net.md
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
# 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.
|
||||||
Reference in New Issue
Block a user