Compare commits

..

28 Commits

Author SHA1 Message Date
Bas van Rossem
472fcf82b8 docs: finalize domain-test-checklist + session log + roadmap (test safety net, 100% coverage)
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m48s
2026-07-23 15:40:49 +02:00
Bas van Rossem
a53a253f91 test(admin): cover lib/api fetch wrapper (close Phase C coverage gap) 2026-07-23 15:39:25 +02:00
Bas van Rossem
a30441b2a2 test(worker): Stopwatch + reconcile — fill gaps + lifecycle test
Fill the Stopwatch domain to 100% coverage (Stopwatch.tsx + stopwatch.ts,
both now 100% stmts/branch/funcs/lines) and add the reconcile lifecycle test.

New tests cover: idle display-tap start (and no-op without a handling),
pair-count stepper floor + free-typed entry (non-positive ignored), clearing
the handling via the empty option, stop/discard onSuccess reset, discard
arm-window expiry, clearing a pending discard timer on reconcile and on
unmount, and the reconcile lifecycle — running locally, then a poll without
the local session resets the timer and shows the admin-stopped notice.

No behaviour changes. Five defensive/unreachable guards in Stopwatch.tsx are
annotated /* c8 ignore next -- <reason> */ (comment-only): the
paused_seconds ?? 0 coalesce (contract types it non-null) and the early-return
guards in handleSelectZool/handleStart/handleStop/handleDiscard (each triggered
by a control disabled or unrendered in that state).

No bugs found.
2026-07-23 15:33:03 +02:00
Bas van Rossem
75c5a9f350 test(admin): Activities + app/nav — fill gaps + lifecycle test
Fill coverage gaps and lock in behavior for the Activities screen and
app/nav (Sidebar) domain (plan task B-D6). All four domain src files
(screens/Activities.tsx, api/activities.ts, App.tsx, components/Sidebar.tsx)
now at 100% stmts/branch/funcs/lines.

- Activities.test.tsx: insole-type toggle select/deselect (both branches),
  reduced-subset create, add disabled when no types, edit insole types,
  cancel-without-save, empty-types-defaults-all editor, empty state,
  error-falls-back-to-empty, and the create->edit types->reorder->delete
  lifecycle test.
- Sidebar.test.tsx (new): brand + nav links + email, active-route highlight,
  end-match keeps Live inactive off-root, nav click switches screen + moves
  highlight, Uitloggen calls signOut, email hidden without useMe data.
- Activities.tsx: three /* c8 ignore next */ annotations on the defensive
  early-return guards in handleAdd/handleSave/handleMove (each mirrored by a
  disabled button, so unreachable via the UI). Comment-only, no behavior change.

No bugs found — the domain code was already correct; the tests lock it in.
2026-07-23 15:18:59 +02:00
Bas van Rossem
8a6bc9ea7d test(admin): Users — fill gaps + lifecycle test 2026-07-23 15:07:12 +02:00
Bas van Rossem
5aa6febd65 test(admin): Reports + date-range — fill gaps + lifecycle test 2026-07-23 14:53:38 +02:00
Bas van Rossem
e7eefe8f62 test(admin): Sessions + SessionForm — fill gaps + lifecycle test
Drives Sessions.tsx and SessionForm.tsx to 100% stmts/branch/funcs/lines.

Added gap tests: sessions error state, in-filter empty message, row
placeholders (missing worker/activity/type/date), frozen worked-time for a
paused active session, edit-form open, and form cancel; SessionForm create/edit
validation paths, non-400 error surfacing, null serialization (empty zooltype +
notitie, status=active clears end_time), pair/pause floors, and the defensive
no-session edit guard.

Lifecycle test (Sessions.test.tsx): open create form -> fill a valid manual
session -> it appears in the list -> edit the row -> the change persists, driven
by a stateful apiFetch mock.

Annotated one unreachable branch: localToIso's Number.isNaN guard is c8-ignored
(a datetime-local input only yields a valid datetime or empty).

No bugs found; no behavior changes.
2026-07-23 14:44:32 +02:00
Bas van Rossem
77db308e41 test(admin): Live view — fill gaps + lifecycle test
Drove Live.tsx from 93.24% to 100% (stmts/branch/funcs/lines); elapsed.ts already 100%. Added tests for the error state, non-array payload fallback, placeholder labels (missing name/activity/type), the busy/disabled action buttons, the loading state, and a combined running+paused lifecycle test. No production behavior changed; no bugs found.
2026-07-23 14:30:26 +02:00
Bas van Rossem
64911a0052 test(admin): Auth (admin-only gate) — fill gaps + lifecycle test 2026-07-23 14:25:04 +02:00
Bas van Rossem
e174c29a77 test(worker): lib/api + app/nav — fill gaps + lifecycle test 2026-07-23 14:15:48 +02:00
Bas van Rossem
32bdaa3e31 test(worker): Account — fill gaps + lifecycle test 2026-07-23 14:07:14 +02:00
Bas van Rossem
13cbc50c4f test(worker): History — fill gaps + lifecycle test 2026-07-23 14:03:04 +02:00
Bas van Rossem
36d4754722 test(worker): Auth (context/storage/login) — fill gaps + lifecycle test
Drive the worker Auth domain to 100% coverage:
- AuthContext.tsx: 91.3/80 -> 100/100 (signIn body, mount-restore branches, useAuth guard)
- Login.tsx: 78/50 -> 100/100 (handleSubmit success/error/pending, submit disable)
- auth-storage.ts: already 100 (no change)

New Login.test.tsx adds the domain lifecycle test: from the unauthenticated app,
a successful login stores the token and flips the context to the authenticated
tabbed shell. Extended AuthContext.test.tsx covers signIn delegation, mount
restore (token present/absent), and the out-of-provider throw.

No bugs found; code was already correct. Note: the design checklist's
'clears on 401' worker behavior is not implemented (no global 401 interceptor)
and is left as-is (missing feature, not a defect).
2026-07-23 13:50:33 +02:00
Bas van Rossem
9c99d56f99 test(api): Admin users — fill gaps + lifecycle test
Fill the remaining coverage gaps in src/routes/admin-users.ts and add the
domain lifecycle test, bringing the file to 100% stmts/branch/funcs/lines.

New tests (apps/api/test/admin-users.test.ts):
- password route: 404 unknown id; 400 when better-auth setUserPassword throws
  (driven via a spy so the catch path is deterministic).
- toListItem: a null DB role lists as 'worker' (nullish-coalescing fallback).
- role route: 400 on an invalid role body; 404 on an unknown id.
- reactivate route: 404 on an unknown id.

Lifecycle (apps/api/test/integration/user-lifecycle.test.ts): admin creates a
worker -> worker signs in and the bearer authorizes /api/me -> admin deactivates
(session rows deleted, so the pre-issued token 401s and fresh sign-in is blocked)
-> admin reactivates -> sign-in works again.

The two last-admin guards (role + deactivate routes) are unreachable over HTTP
(the self-demote / self-deactivate guards above already handle caller===target,
so any caller reaching them is a distinct active admin and
activeAdminsExcluding(target) >= 1). Annotated with /* c8 ignore */ + reasons
rather than left as silent gaps; the guard logic is unit-tested via
activeAdminsExcluding.

No production behavior changed; no bugs found.
2026-07-23 13:43:59 +02:00
Bas van Rossem
31d13a8381 test(api): Reports + admin export — fill gaps + lifecycle test
Drives the report/export parts of src/routes/admin.ts to 100%
stmts/branch/funcs/lines. Adds one lifecycle test plus gap tests for the
previously-uncovered branches; no production behavior changed.

Tests added (apps/api/test):
- report.test.ts: invalid insole_type / non-numeric activity_id → 400;
  activity filter narrows; null insole_type bucketed "Onbekend"; null
  duration counts as 0 worked; export 400 on missing/inverted range.
  Migrated the report + export describes to resetDb(beforeEach) so exact
  totals are order-independent (removed the activity_id isolation
  workaround in the all-users export test).
- integration/report-aggregation.test.ts: lifecycle — 2 workers × 2
  activities × 2 types → exact totals + every by_worker/by_activity/
  by_type row (values and worked-desc order) → user_id filter narrows.

Defensive annotations in admin.ts report loop (behavior unchanged):
- pausedSeconds/pairCount ?? 0 — NOT NULL columns, DB-impossible nulls.
- user_name/activity_name ?? 'Onbekend' — unreachable; userId FK cascades
  on delete and activityId FK is enforced (libsql enforces FKs), so every
  session always joins to a real user and activity.

No bugs found; the code was already correct.
2026-07-22 13:03:04 +02:00
Bas van Rossem
bdf65ae685 test(api): Admin sessions — fill gaps + lifecycle test
Fills the session-part coverage gaps in src/routes/admin.ts (cross-user list,
active list, manual create/edit, admin stop/discard) and adds the admin↔worker
lifecycle test.

New route-level tests (apps/api/test/admin.test.ts):
- manual create: 400 on schema-invalid body; 400 when paused_seconds > span.
- edit: 404 non-numeric id; 400 schema-invalid body; 400 unparseable start_time;
  400 paused_seconds > span; 404 unknown activity.
- stop: 404 non-numeric/unknown id; folds an open pause into paused_seconds.
- discard: 404 non-numeric/unknown id; 409 when already closed.

Lifecycle (apps/api/test/integration/admin-worker-session.test.ts): worker
starts → admin stops via the admin endpoint → session lands in /api/admin/report
and /api/admin/export with matching totals (uses resetDb for exact counts).

admin.ts session parts now at 100% stmts/lines; every session-part branch
covered. The one remaining session-part branch (row.pausedSeconds ?? 0 in the
stop route) is c8-ignored — paused_seconds is NOT NULL DEFAULT 0, so the null
branch is DB-impossible. The remaining uncovered branches in admin.ts are the
report/export parts, owned by the Reports domain (B-A6).

No behavior-changing bugs found; all session-part code was already correct.
2026-07-22 12:41:52 +02:00
Bas van Rossem
b72076ee15 test(api): Worker export/CSV — fill gaps + lifecycle test
Drove src/lib/csv.ts to 100% stmts/branch/funcs/lines (was 97.95%/55.55%):
- added a null-fallback row test covering every ?? default (activityName,
  userName, insoleType→Kurk, pairCount→2, duration/paused→0) and the null
  end-time branch (line 65).
- added CSV-escaping edge tests: commas, embedded quotes, and newlines inside
  a field stay inside the quoted cell.
- added formatDuration edge (hours >99 → 100:00:00).
- added a /api/export ordering-by-start-time behavior test and adopted
  resetDb per-test isolation in the export suite.

No bugs found; CSV code behaves correctly. sessions.ts /api/export route
already at 100%. Full api suite green (150 tests).
2026-07-22 12:24:04 +02:00
Bas van Rossem
9ecbf952d3 test(api): Work-sessions — fill gaps + lifecycle test
Drives src/routes/sessions.ts and src/lib/work-session.ts to 100%
stmts/branch/funcs/lines (branch was 84.12% / 83.33%).

- sessions.test.ts: added error/edge branch tests — malformed-JSON start
  (400), non-numeric id on pause/resume/stop/discard (404), pause/resume of a
  no-longer-active session (409), discard missing (404) + already-closed (409),
  stop missing (404), and discard ownership scoping (user B → 404).
- work-session.test.ts: mapper unit tests — null insoleType, nullish
  pausedSeconds → 0, null durationSeconds/notes, full-field shape with
  activity/user opts, and opts defaulting to undefined.
- test/integration/worker-session-lifecycle.test.ts: the anchor lifecycle test
  (start→pause→resume→stop→history→export).

No bugs found — the code was correct. The two remaining uncovered branches were
the defensive `row.pausedSeconds ?? 0` guards in resume/stop; pausedSeconds is
NOT NULL DEFAULT 0 in the schema, so the null branch is unreachable via any
route. Annotated both with `/* c8 ignore next -- reason */` (comment only, no
behavior change).
2026-07-22 12:14:32 +02:00
Bas van Rossem
af7251398c test(api): Activities — fill gaps + lifecycle test 2026-07-22 12:01:03 +02:00
Bas van Rossem
e6e4ca0a1e test(api): Auth & /me — fill gaps + lifecycle test 2026-07-22 11:50:32 +02:00
Bas van Rossem
2fa8aa9154 test(api): Infra (health/db/schema/seed/env/migrate) — fill gaps + lifecycle test
Drives all infra src files to 100% (env.ts, db/client.ts, db/ensure-dir.ts,
db/migrate.ts, db/seed.ts, routes/health.ts).

- env.test.ts: dev defaults when unset, overrides when set (PORT coerced),
  CORS_ORIGINS parsing (trim + drop empties) and empty-fallback.
- db.test.ts: runMigrations idempotency; ensureDbDir no-op for non-file URLs
  and bare filenames (branch coverage).
- seed.test.ts: production guard — no dev users seeded when NODE_ENV=production.
- c8-ignore the migrate.ts/seed.ts CLI direct-run blocks (unreachable under vitest).

No lifecycle test (infra domain, per plan). No bugs found — infra code correct;
only coverage gaps. All 112 API tests green; typecheck + oxlint clean.
2026-07-22 11:39:34 +02:00
Bas van Rossem
0564fab1e6 docs: domain-test-checklist skeleton + coverage baseline (Plane SL-70) 2026-07-22 11:28:01 +02:00
Bas van Rossem
f872daf117 test(api): add resetDb helper + per-test isolation (proof: sessions suite) 2026-07-22 11:28:01 +02:00
Bas van Rossem
3c79e51e99 test: add @vitest/coverage-v8 + test:coverage to all workspaces 2026-07-22 11:28:01 +02:00
Bas van Rossem
715e3c9e30 docs(plan): per-domain test safety net implementation plan 2026-07-22 11:20:02 +02:00
Bas van Rossem
32412fa88b docs(spec): set near-100% per-domain coverage as the working target 2026-07-22 11:14:58 +02:00
Bas van Rossem
f547cef8e8 docs(spec): per-domain test safety net design 2026-07-22 11:12:13 +02:00
Bas van Rossem
37f7bd695c docs: Phase 4 descoped — QR is an external app-URL link, no workbench build 2026-07-22 10:51:36 +02:00
69 changed files with 5841 additions and 43 deletions

View File

@@ -9,7 +9,8 @@
"preview": "vite preview",
"typecheck": "tsc -b",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@solelog/shared": "workspace:*",
@@ -26,6 +27,7 @@
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^3",
"jsdom": "^25.0.0",
"typescript": "^5.7.2",
"vite": "^7.0.0",

View File

@@ -1,7 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { filtersToQuery, downloadExport, type ReportFilters } from './reports';
vi.mock('../lib/auth-storage', () => ({ getToken: () => 'tok-123' }));
// Mutable so a single test can exercise the no-token branch.
const authState = vi.hoisted(() => ({ token: 'tok-123' as string | null }));
vi.mock('../lib/auth-storage', () => ({ getToken: () => authState.token }));
const FILTERS: ReportFilters = {
from: '2026-06-15T00:00:00.000Z',
@@ -30,6 +32,9 @@ describe('filtersToQuery', () => {
});
describe('downloadExport', () => {
beforeEach(() => {
authState.token = 'tok-123';
});
afterEach(() => vi.restoreAllMocks());
it('fetches with the bearer token and triggers a download', async () => {
@@ -60,6 +65,50 @@ describe('downloadExport', () => {
expect(revokeUrl).toHaveBeenCalledWith('blob:x');
});
it('sends no Authorization header when there is no token', async () => {
authState.token = null;
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob(['csv'])),
headers: new Headers({ 'content-disposition': 'attachment; filename="x.csv"' }),
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn(() => 'blob:x'),
revokeObjectURL: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(vi.fn());
await downloadExport(FILTERS);
expect(fetchMock.mock.calls[0][1].headers).toEqual({});
});
it('falls back to a default filename when no content-disposition header', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob(['csv'])),
headers: new Headers({}),
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn(() => 'blob:x'),
revokeObjectURL: vi.fn(),
});
let downloaded = '';
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function (this: HTMLAnchorElement) {
downloaded = this.download;
},
);
await downloadExport(FILTERS);
expect(downloaded).toBe('solelog-report.csv');
});
it('throws on a non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
await expect(downloadExport(FILTERS)).rejects.toThrow();

View File

@@ -111,4 +111,15 @@ describe('AuthContext admin gate', () => {
expect(window.location.pathname).toBe('/');
expect(getToken()).toBeNull();
});
it('useAuth throws when rendered outside an AuthProvider', () => {
function Orphan() {
useAuth();
return null;
}
// React logs the render error to console.error; silence it for the intentional throw.
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<Orphan />)).toThrow('useAuth must be used within an AuthProvider');
spy.mockRestore();
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -195,4 +195,163 @@ describe('SessionForm', () => {
expect(await screen.findByText(/Controleer de ingevoerde gegevens/)).toBeInTheDocument();
});
it('shows a generic error when the API fails with a non-400', async () => {
mockApiFetch.mockImplementation((path: string) => {
if (path === '/api/admin/users') return Promise.resolve(USERS as never);
if (path === '/api/activities') return Promise.resolve(ACTIVITIES as never);
return Promise.reject(new Error('network down'));
});
renderForm({ mode: 'edit', session: makeSession(), onClose: vi.fn() });
await screen.findByLabelText('Handeling');
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
expect(await screen.findByText(/Opslaan mislukt/)).toBeInTheDocument();
});
it('create: blocks submit and warns when handeling or starttijd is missing', async () => {
const onClose = vi.fn();
renderForm({ mode: 'create', onClose });
await screen.findByLabelText('Handeling');
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
expect(await screen.findByText(/Vul handeling en starttijd in/)).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
expect(mockApiFetch).not.toHaveBeenCalledWith(
'/api/admin/sessions',
expect.objectContaining({ method: 'POST' }),
);
});
it('create: warns when medewerker, zooltype or eindtijd is missing', async () => {
renderForm({ mode: 'create', onClose: vi.fn() });
await screen.findByRole('option', { name: 'Frezen' });
await userEvent.selectOptions(screen.getByLabelText('Handeling'), '10');
await userEvent.type(screen.getByLabelText('Starttijd'), '2026-06-17T08:00');
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
expect(await screen.findByText(/Vul medewerker, zooltype/)).toBeInTheDocument();
});
it('create: trims the notitie into the payload', async () => {
renderForm({ mode: 'create', onClose: vi.fn() });
const worker = await screen.findByLabelText('Medewerker');
await screen.findByRole('option', { name: 'Jan' });
await userEvent.selectOptions(worker, 'u1');
await userEvent.selectOptions(screen.getByLabelText('Handeling'), '10');
await userEvent.selectOptions(screen.getByLabelText('Zooltype'), 'Kurk');
await userEvent.type(screen.getByLabelText('Starttijd'), '2026-06-17T08:00');
await userEvent.type(screen.getByLabelText('Eindtijd'), '2026-06-17T09:00');
await userEvent.type(screen.getByLabelText('Notitie'), ' let op ');
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/sessions',
expect.objectContaining({ method: 'POST' }),
),
);
const call = mockApiFetch.mock.calls.find((c) => c[0] === '/api/admin/sessions');
const body = JSON.parse((call![1] as RequestInit).body as string);
expect(body.notes).toBe('let op');
});
it('edit: clearing zooltype and notitie serializes them to null', async () => {
renderForm({ mode: 'edit', session: makeSession(), onClose: vi.fn() });
await screen.findByRole('option', { name: 'Lijmen' });
await userEvent.selectOptions(screen.getByLabelText('Zooltype'), '');
await userEvent.clear(screen.getByLabelText('Notitie'));
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/sessions/7',
expect.objectContaining({ method: 'PUT' }),
),
);
const call = mockApiFetch.mock.calls.find((c) => c[0] === '/api/admin/sessions/7');
const body = JSON.parse((call![1] as RequestInit).body as string);
expect(body.insole_type).toBeNull();
expect(body.notes).toBeNull();
});
it('edit: setting status to actief clears the end time', async () => {
renderForm({ mode: 'edit', session: makeSession(), onClose: vi.fn() });
await screen.findByLabelText('Status');
await userEvent.selectOptions(screen.getByLabelText('Status'), 'active');
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/sessions/7',
expect.objectContaining({ method: 'PUT' }),
),
);
const call = mockApiFetch.mock.calls.find((c) => c[0] === '/api/admin/sessions/7');
const body = JSON.parse((call![1] as RequestInit).body as string);
expect(body.status).toBe('active');
expect(body.end_time).toBeNull();
});
it('clears the handeling selection back to empty and re-blocks submit', async () => {
renderForm({ mode: 'edit', session: makeSession(), onClose: vi.fn() });
await screen.findByRole('option', { name: 'Lijmen' });
await userEvent.selectOptions(screen.getByLabelText('Handeling'), '');
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
expect(await screen.findByText(/Vul handeling en starttijd in/)).toBeInTheDocument();
});
it('clamps aantal paren to 1 and pauze to 0 on invalid input', async () => {
renderForm({ mode: 'edit', session: makeSession(), onClose: vi.fn() });
const pairs = (await screen.findByLabelText('Aantal paren')) as HTMLInputElement;
const paused = screen.getByLabelText('Pauze (minuten)') as HTMLInputElement;
fireEvent.change(pairs, { target: { value: '' } });
expect(pairs.value).toBe('1');
fireEvent.change(pairs, { target: { value: '5' } });
expect(pairs.value).toBe('5');
fireEvent.change(paused, { target: { value: '' } });
expect(paused.value).toBe('0');
fireEvent.change(paused, { target: { value: '15' } });
expect(paused.value).toBe('15');
});
it('edit mode without a session is a no-op on submit (defensive guard)', async () => {
renderForm({ mode: 'edit', onClose: vi.fn() });
await screen.findByRole('option', { name: 'Frezen' });
await userEvent.selectOptions(screen.getByLabelText('Handeling'), '10');
await userEvent.type(screen.getByLabelText('Starttijd'), '2026-06-17T08:00');
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
// Passes validation but the missing session short-circuits before any PUT.
await waitFor(() =>
expect(
mockApiFetch.mock.calls.some((c) => (c[1] as RequestInit | undefined)?.method === 'PUT'),
).toBe(false),
);
});
it('edit: an unparseable prefilled starttijd falls back to an empty field', async () => {
renderForm({
mode: 'edit',
session: makeSession({ start_time: 'nonsense', end_time: 'nonsense' }),
onClose: vi.fn(),
});
const start = (await screen.findByLabelText('Starttijd')) as HTMLInputElement;
expect(start.value).toBe('');
});
});

View File

@@ -32,6 +32,7 @@ function isoToLocal(iso: string | null): string {
function localToIso(local: string): string | null {
if (!local) return null;
const d = new Date(local);
/* c8 ignore next -- a datetime-local input only yields a valid datetime or '', never an unparseable value */
if (Number.isNaN(d.getTime())) return null;
return d.toISOString();
}

View File

@@ -0,0 +1,95 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import Sidebar from './Sidebar';
const signOut = vi.hoisted(() => vi.fn());
const meState = vi.hoisted(() => ({ email: 'admin@solelog.local' as string | undefined }));
vi.mock('../auth/AuthContext', () => ({
useAuth: () => ({ isAuthed: true, signIn: vi.fn(), signOut }),
}));
vi.mock('../api/me', () => ({
useMe: () => ({ data: meState.email ? { user: { email: meState.email } } : undefined }),
}));
// Render the Sidebar next to a stub route table so navigation can be observed.
function renderSidebar(initial = '/') {
return render(
<MemoryRouter initialEntries={[initial]}>
<Sidebar />
<Routes>
<Route path="/" element={<div>SCREEN Live</div>} />
<Route path="/handelingen" element={<div>SCREEN Handelingen</div>} />
<Route path="/sessies" element={<div>SCREEN Sessies</div>} />
<Route path="/rapporten" element={<div>SCREEN Rapporten</div>} />
<Route path="/gebruikers" element={<div>SCREEN Gebruikers</div>} />
</Routes>
</MemoryRouter>,
);
}
describe('Sidebar', () => {
afterEach(() => {
signOut.mockReset();
meState.email = 'admin@solelog.local';
vi.restoreAllMocks();
});
it('renders the brand, every nav link, and the signed-in email', () => {
renderSidebar();
expect(screen.getByText('SoleLog Admin')).toBeInTheDocument();
const nav = within(screen.getByRole('navigation'));
for (const label of ['Live', 'Handelingen', 'Sessies', 'Rapporten', 'Gebruikers']) {
expect(nav.getByRole('link', { name: label })).toBeInTheDocument();
}
expect(screen.getByText('admin@solelog.local')).toBeInTheDocument();
});
it('highlights the active route and only that route', () => {
renderSidebar('/');
expect(screen.getByText('SCREEN Live')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Live' }).className).toContain('nav-link-active');
expect(screen.getByRole('link', { name: 'Handelingen' }).className).not.toContain(
'nav-link-active',
);
});
it('keeps Live inactive when a non-root route is active (end match on /)', () => {
renderSidebar('/handelingen');
expect(screen.getByText('SCREEN Handelingen')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Handelingen' }).className).toContain(
'nav-link-active',
);
expect(screen.getByRole('link', { name: 'Live' }).className).not.toContain('nav-link-active');
});
it('clicking a nav link switches the rendered screen and moves the highlight', async () => {
const user = userEvent.setup();
renderSidebar('/');
expect(screen.getByText('SCREEN Live')).toBeInTheDocument();
await user.click(screen.getByRole('link', { name: 'Rapporten' }));
expect(screen.getByText('SCREEN Rapporten')).toBeInTheDocument();
expect(screen.queryByText('SCREEN Live')).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Rapporten' }).className).toContain('nav-link-active');
expect(screen.getByRole('link', { name: 'Live' }).className).not.toContain('nav-link-active');
});
it('clicking Uitloggen calls signOut', async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole('button', { name: 'Uitloggen' }));
expect(signOut).toHaveBeenCalledTimes(1);
});
it('hides the email span when useMe has no data', () => {
meState.email = undefined;
renderSidebar();
expect(screen.queryByText('admin@solelog.local')).not.toBeInTheDocument();
// The logout control is still present.
expect(screen.getByRole('button', { name: 'Uitloggen' })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,50 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import UserForm from './UserForm';
function renderForm(over: Partial<Parameters<typeof UserForm>[0]> = {}) {
const props = { onSubmit: vi.fn(), onCancel: vi.fn(), pending: false, error: null, ...over };
render(<UserForm {...props} />);
return props;
}
describe('UserForm', () => {
it('submits trimmed name/email with the selected role', async () => {
const props = renderForm();
await userEvent.type(screen.getByLabelText('Naam'), ' Nieuw ');
await userEvent.type(screen.getByLabelText('E-mail'), ' nieuw@x.nl ');
await userEvent.type(screen.getByLabelText('Wachtwoord'), 'wachtwoord-1');
await userEvent.selectOptions(screen.getByLabelText('Rol'), 'admin');
await userEvent.click(screen.getByRole('button', { name: 'Aanmaken' }));
expect(props.onSubmit).toHaveBeenCalledWith({
name: 'Nieuw',
email: 'nieuw@x.nl',
password: 'wachtwoord-1',
role: 'admin',
});
});
it('warns and blocks submit when the password is shorter than 8', async () => {
const props = renderForm();
await userEvent.type(screen.getByLabelText('Naam'), 'Nieuw');
await userEvent.type(screen.getByLabelText('E-mail'), 'nieuw@x.nl');
await userEvent.type(screen.getByLabelText('Wachtwoord'), 'kort');
expect(screen.getByText('Minstens 8 tekens.')).toBeInTheDocument();
// fireEvent.submit bypasses native constraint validation to exercise the JS guard.
fireEvent.submit(screen.getByTestId('user-form'));
expect(props.onSubmit).not.toHaveBeenCalled();
});
it('renders the error message from the error prop', () => {
renderForm({ error: 'E-mailadres bestaat al of ongeldig.' });
expect(screen.getByText('E-mailadres bestaat al of ongeldig.')).toBeInTheDocument();
});
it('disables submit while pending and cancels via onCancel', async () => {
const props = renderForm({ pending: true });
expect(screen.getByRole('button', { name: 'Aanmaken' })).toBeDisabled();
await userEvent.click(screen.getByRole('button', { name: 'Annuleer' }));
expect(props.onCancel).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { apiFetch, signIn, ApiError } from './api';
import { getToken, setToken } from './auth-storage';
describe('api client', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('attaches the bearer token to requests', async () => {
const mock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
vi.stubGlobal('fetch', mock);
setToken('tok');
await apiFetch('/api/me');
expect(mock).toHaveBeenCalledTimes(1);
const [url, init] = mock.mock.calls[0];
expect(url).toBe('http://localhost:3000/api/me');
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer tok');
});
it('omits the Authorization header when no token is stored', async () => {
const mock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', mock);
await apiFetch('/api/me');
const [, init] = mock.mock.calls[0];
expect(new Headers(init.headers).has('Authorization')).toBe(false);
});
it('throws ApiError on a non-2xx response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })));
await expect(apiFetch('/api/me')).rejects.toMatchObject({ status: 401 });
await expect(apiFetch('/api/me')).rejects.toBeInstanceOf(ApiError);
});
it('returns undefined when the response body is empty', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 200 })));
await expect(
apiFetch('/api/admin/sessions/1/stop', { method: 'POST' }),
).resolves.toBeUndefined();
});
it('sets Content-Type to application/json when a body is present and none is set', async () => {
const mock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', mock);
await apiFetch('/api/admin/sessions', { method: 'POST', body: JSON.stringify({ a: 1 }) });
const [, init] = mock.mock.calls[0];
expect(new Headers(init.headers).get('Content-Type')).toBe('application/json');
});
it('does not override an explicitly provided Content-Type header', async () => {
const mock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', mock);
await apiFetch('/x', {
method: 'POST',
body: 'raw',
headers: { 'Content-Type': 'text/plain' },
});
const [, init] = mock.mock.calls[0];
expect(new Headers(init.headers).get('Content-Type')).toBe('text/plain');
});
it('signIn stores the token from the set-auth-token header', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
new Response(null, { status: 200, headers: { 'set-auth-token': 'xyz' } }),
),
);
await signIn('a@b.c', 'pw');
expect(getToken()).toBe('xyz');
});
it('signIn throws ApiError when credentials are rejected', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })));
await expect(signIn('a@b.c', 'pw')).rejects.toBeInstanceOf(ApiError);
});
it('signIn throws when the set-auth-token header is missing', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })));
await expect(signIn('a@b.c', 'pw')).rejects.toMatchObject({ status: 500 });
});
});

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { dayStartISO, dayEndISO, thisWeek, thisMonth } from './date-range';
import { allTime, dayStartISO, dayEndISO, thisWeek, thisMonth, toDateStr } from './date-range';
describe('date-range helpers', () => {
it('dayStartISO/dayEndISO bound a local day', () => {
@@ -25,4 +25,15 @@ describe('date-range helpers', () => {
expect(from).toBe('2026-06-01');
expect(to).toBe('2026-06-17');
});
it('allTime spans a fixed floor to today', () => {
const ref = new Date('2026-06-17T12:00:00');
const { from, to } = allTime(ref);
expect(from).toBe('2000-01-01');
expect(to).toBe('2026-06-17');
});
it('toDateStr zero-pads month and day', () => {
expect(toDateStr(new Date(2026, 0, 5, 12, 0, 0))).toBe('2026-01-05');
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -163,4 +163,232 @@ describe('Activities', () => {
expect(screen.getByRole('button', { name: 'Verplaats Frezen omlaag' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'Verplaats Polijsten omhoog' })).toBeEnabled();
});
it('type toggles deselect and reselect insole types (add form)', async () => {
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen');
const kurk = screen.getByRole('button', { name: 'Kurk' });
expect(kurk).toHaveAttribute('aria-pressed', 'true');
// Remove branch of toggle().
await user.click(kurk);
expect(kurk).toHaveAttribute('aria-pressed', 'false');
// Re-select — add branch of toggle().
await user.click(kurk);
expect(kurk).toHaveAttribute('aria-pressed', 'true');
});
it('adds a handeling with a reduced insole-type subset', async () => {
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen');
await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Randjes');
// Default is all three; drop Berk so the POST carries only the remaining subset.
await user.click(screen.getByRole('button', { name: 'Berk' }));
await user.click(screen.getByRole('button', { name: 'Stap toevoegen' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities', {
method: 'POST',
body: JSON.stringify({ name: 'Randjes', insole_types: ['Kurk', '3D'] }),
});
});
});
it('disables Stap toevoegen when every insole type is deselected', async () => {
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen');
await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Randjes');
const add = screen.getByRole('button', { name: 'Stap toevoegen' });
expect(add).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Kurk' }));
await user.click(screen.getByRole('button', { name: 'Berk' }));
await user.click(screen.getByRole('button', { name: '3D' }));
expect(add).toBeDisabled();
});
it('editing insole types PUTs the updated subset', async () => {
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen'); // Frezen has ['Kurk', 'Berk']
await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' }));
const li = screen.getByDisplayValue('Frezen').closest('li')!;
// Add 3D via the edit-form toggles.
await user.click(within(li).getByRole('button', { name: '3D' }));
await user.click(within(li).getByRole('button', { name: 'Opslaan' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/1', {
method: 'PUT',
body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk', 'Berk', '3D'] }),
});
});
});
it('cancel exits edit mode without saving', async () => {
const user = userEvent.setup();
renderActivities();
await screen.findByText('Frezen');
await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' }));
expect(screen.getByDisplayValue('Frezen')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Annuleren' }));
expect(screen.queryByDisplayValue('Frezen')).not.toBeInTheDocument();
expect(mockApiFetch).not.toHaveBeenCalledWith(
'/api/activities/1',
expect.objectContaining({ method: 'PUT' }),
);
});
it('editing an activity with no insole types pre-selects all three', async () => {
const user = userEvent.setup();
mockApiFetch.mockResolvedValue([
{
id: 9,
name: 'Blanco',
insole_types: [],
created_at: '2026-06-17T00:00:00.000Z',
sort_order: 0,
},
]);
renderActivities();
await screen.findByText('Blanco');
await user.click(screen.getByRole('button', { name: 'Bewerk Blanco' }));
const li = screen.getByDisplayValue('Blanco').closest('li')!;
for (const type of ['Kurk', 'Berk', '3D']) {
expect(within(li).getByRole('button', { name: type })).toHaveAttribute(
'aria-pressed',
'true',
);
}
});
it('shows the empty state when there are no activities', async () => {
mockApiFetch.mockResolvedValue([]);
renderActivities();
expect(
await screen.findByText('Nog geen stappen. Voeg er een toe hierboven.'),
).toBeInTheDocument();
expect(screen.getByText('Huidige stappen (0)')).toBeInTheDocument();
});
it('falls back to the empty state when the query errors (no dedicated error UI)', async () => {
mockApiFetch.mockRejectedValue(new Error('boom'));
renderActivities();
expect(
await screen.findByText('Nog geen stappen. Voeg er een toe hierboven.'),
).toBeInTheDocument();
});
// Lifecycle: create → edit insole types → reorder → delete, each step reflected in the list.
it('lifecycle: create → edit insole types → reorder → delete', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const user = userEvent.setup();
let list: Activity[] = [
{
id: 1,
name: 'Lijmen',
insole_types: ['3D'],
created_at: '2026-06-17T00:00:00.000Z',
sort_order: 0,
},
];
let nextId = 2;
mockApiFetch.mockImplementation((path: string, init?: RequestInit) => {
const method = init?.method ?? 'GET';
if (path === '/api/activities' && method === 'GET') return Promise.resolve(list);
if (path === '/api/activities' && method === 'POST') {
const body = JSON.parse(init!.body as string);
const created: Activity = {
id: nextId++,
name: body.name,
insole_types: body.insole_types,
created_at: '2026-07-23T00:00:00.000Z',
sort_order: list.length,
};
list = [...list, created];
return Promise.resolve(created);
}
if (path === '/api/activities/reorder' && method === 'PUT') {
const { ids } = JSON.parse(init!.body as string) as { ids: number[] };
list = ids.map((id, i) => ({ ...list.find((a) => a.id === id)!, sort_order: i }));
return Promise.resolve(list);
}
const idMatch = path.match(/^\/api\/activities\/(\d+)$/);
if (idMatch && method === 'PUT') {
const id = Number(idMatch[1]);
const body = JSON.parse(init!.body as string);
list = list.map((a) =>
a.id === id ? { ...a, name: body.name, insole_types: body.insole_types } : a,
);
return Promise.resolve(list.find((a) => a.id === id)!);
}
if (idMatch && method === 'DELETE') {
const id = Number(idMatch[1]);
list = list.filter((a) => a.id !== id);
return Promise.resolve({ success: true });
}
return Promise.reject(new Error(`unexpected ${method} ${path}`));
});
const { container } = renderActivities();
// 1. Create Frezen (defaults to all three types).
await screen.findByText('Lijmen');
await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Frezen');
await user.click(screen.getByRole('button', { name: 'Stap toevoegen' }));
expect(await screen.findByText('Frezen')).toBeInTheDocument();
expect(screen.getByText('Huidige stappen (2)')).toBeInTheDocument();
// 2. Edit its insole types down to just Kurk.
await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' }));
const editLi = screen.getByDisplayValue('Frezen').closest('li')!;
await user.click(within(editLi).getByRole('button', { name: 'Berk' }));
await user.click(within(editLi).getByRole('button', { name: '3D' }));
await user.click(within(editLi).getByRole('button', { name: 'Opslaan' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/2', {
method: 'PUT',
body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk'] }),
});
});
// 3. Reorder: move Frezen above Lijmen.
await user.click(await screen.findByRole('button', { name: 'Verplaats Frezen omhoog' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/reorder', {
method: 'PUT',
body: JSON.stringify({ ids: [2, 1] }),
});
});
await waitFor(() => {
const names = Array.from(container.querySelectorAll('.activity-name')).map(
(n) => n.textContent,
);
expect(names).toEqual(['Frezen', 'Lijmen']);
});
// 4. Delete Frezen.
await user.click(screen.getByRole('button', { name: 'Verwijder Frezen' }));
await waitFor(() => {
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/2', { method: 'DELETE' });
});
await waitFor(() => {
expect(screen.queryByText('Frezen')).not.toBeInTheDocument();
});
expect(screen.getByText('Huidige stappen (1)')).toBeInTheDocument();
});
});

View File

@@ -92,6 +92,7 @@ export default function Activities() {
const addEnabled = newName.trim().length > 0 && newTypes.length > 0;
function handleAdd() {
/* c8 ignore next -- defensive: the Stap toevoegen button is disabled when !addEnabled */
if (!addEnabled) return;
createActivity.mutate(
{ name: newName.trim(), insole_types: newTypes },
@@ -115,6 +116,7 @@ export default function Activities() {
}
function handleSave(id: number) {
/* c8 ignore next -- defensive: the Opslaan button is disabled when name/types are empty */
if (editName.trim().length === 0 || editTypes.length === 0) return;
updateActivity.mutate(
{ id, input: { name: editName.trim(), insole_types: editTypes } },
@@ -131,6 +133,7 @@ export default function Activities() {
function handleMove(index: number, direction: -1 | 1) {
const target = index + direction;
/* c8 ignore next -- defensive: up/down buttons are disabled at the list bounds */
if (target < 0 || target >= activities.length) return;
const ids = activities.map((a) => a.id);
[ids[index], ids[target]] = [ids[target], ids[index]];

View File

@@ -194,4 +194,101 @@ describe('Live', () => {
await vi.advanceTimersByTimeAsync(2000);
expect(screen.getByText('00:00:12')).toBeInTheDocument();
});
it('shows the loading state before the first response resolves', () => {
mockApiFetch.mockReturnValue(new Promise(() => {})); // never resolves → stays loading
renderLive();
expect(screen.getByText('Laden…')).toBeInTheDocument();
});
it('shows the error state when the query fails', async () => {
mockApiFetch.mockRejectedValue(new Error('boom'));
renderLive();
expect(await screen.findByText('Kon gegevens niet laden.')).toBeInTheDocument();
expect(screen.queryByText(/Actief nu/)).not.toBeInTheDocument();
});
it('treats a non-array payload as no active sessions', async () => {
mockApiFetch.mockResolvedValue({} as unknown as WorkSession[]);
renderLive();
expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument();
expect(screen.getByText('Actief nu (0)')).toBeInTheDocument();
});
it('falls back to placeholder labels and hides the pill when name/activity/type are missing', async () => {
mockApiFetch.mockResolvedValue([
makeSession({ id: 1, user_name: undefined, activity_name: undefined, insole_type: null }),
]);
renderLive();
expect(await screen.findByText('Onbekend')).toBeInTheDocument();
expect(screen.getByText('Onbekende handeling')).toBeInTheDocument();
expect(screen.queryByText('Kurk')).not.toBeInTheDocument();
});
it('disables both action buttons while a stop is in flight', async () => {
const user = userEvent.setup();
mockApiFetch.mockImplementation((path: string) => {
if (path === '/api/admin/sessions/active') {
return Promise.resolve([makeSession({ id: 7, user_name: 'Jan' })]);
}
return new Promise(() => {}); // stop mutation never resolves → stays pending
});
renderLive();
await user.click(await screen.findByRole('button', { name: 'Stop' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Stop' })).toBeDisabled();
});
expect(screen.getByRole('button', { name: 'Annuleer' })).toBeDisabled();
});
// Lifecycle: active-sessions data renders cards, and a paused session shows the paused treatment.
it('lifecycle: renders a running card and a paused card side by side', async () => {
const start = Date.now() - 100_000;
const pausedAt = Date.now() - 40_000;
mockApiFetch.mockResolvedValue([
makeSession({
id: 1,
user_name: 'Jan',
activity_name: 'Frezen',
insole_type: 'Kurk',
pair_count: 4,
paused_at: null,
paused_seconds: 0,
}),
makeSession({
id: 2,
user_name: 'Piet',
activity_name: 'Lijmen',
insole_type: 'Berk',
pair_count: 6,
start_time: new Date(start).toISOString(),
paused_at: new Date(pausedAt).toISOString(),
paused_seconds: 90, // 00:01:30
}),
]);
renderLive();
// Both cards render under the header count.
expect(await screen.findByText('Actief nu (2)')).toBeInTheDocument();
expect(screen.getByText('Jan')).toBeInTheDocument();
expect(screen.getByText('Frezen')).toBeInTheDocument();
expect(screen.getByText('Piet')).toBeInTheDocument();
expect(screen.getByText('Lijmen')).toBeInTheDocument();
// Exactly the paused session shows the paused treatment.
expect(screen.getAllByText('Gepauzeerd')).toHaveLength(1);
expect(screen.getByText('Pauze 00:01:30')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,190 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { MeResponse } from '@solelog/shared';
import Login from './Login';
import App from '../App';
import { AuthProvider } from '../auth/AuthContext';
import { ApiError, signIn as apiSignIn } from '../lib/api';
import { fetchMe } from '../api/me';
import { clearToken, setToken } from '../lib/auth-storage';
// Mock the network layer. signIn stores a token (mirror the real lib/api behaviour);
// apiFetch is stubbed so the authed shell's list queries resolve cleanly in the lifecycle
// test. fetchMe drives the admin-vs-worker gate. useMe stays real (runs on the mocked fetchMe).
vi.mock('../lib/api', async () => {
const actual = await vi.importActual<typeof import('../lib/api')>('../lib/api');
return {
...actual,
signIn: vi.fn(async () => {
setToken('tok');
}),
apiFetch: vi.fn().mockResolvedValue([]),
};
});
// fetchMe (used by the AuthContext gate) is the controllable mock. useMe (used by the shell's
// Sidebar header) is stubbed to a no-data result — the gate, not the header, is under test here.
vi.mock('../api/me', () => ({
fetchMe: vi.fn(),
useMe: () => ({ data: undefined }),
}));
const mockedSignIn = vi.mocked(apiSignIn);
const mockedFetchMe = vi.mocked(fetchMe);
const admin: MeResponse = {
user: { id: 'a1', email: 'admin@solelog.local', name: 'Admin', role: 'admin' },
};
const worker: MeResponse = {
user: { id: 'w1', email: 'worker@solelog.local', name: 'Werker', role: 'worker' },
};
function renderLogin() {
return render(
<AuthProvider>
<Login />
</AuthProvider>,
);
}
async function typeCredentials(user: ReturnType<typeof userEvent.setup>, email: string) {
await user.type(screen.getByLabelText('E-mailadres'), email);
await user.type(screen.getByLabelText('Wachtwoord'), 'pw');
}
describe('Login', () => {
beforeEach(() => {
mockedSignIn.mockReset();
mockedFetchMe.mockReset();
mockedSignIn.mockImplementation(async () => {
setToken('tok');
});
});
afterEach(() => {
clearToken();
vi.clearAllMocks();
});
it('submits the typed credentials to signIn', async () => {
mockedFetchMe.mockResolvedValue(admin);
const user = userEvent.setup();
renderLogin();
await typeCredentials(user, 'admin@solelog.local');
await user.click(screen.getByRole('button', { name: 'Inloggen' }));
await waitFor(() => expect(mockedSignIn).toHaveBeenCalledWith('admin@solelog.local', 'pw'));
// A successful admin sign-in surfaces no error.
expect(screen.queryByText('Inloggen mislukt')).not.toBeInTheDocument();
});
it('shows the admin-only message when a worker signs in (NotAdminError)', async () => {
mockedFetchMe.mockResolvedValue(worker);
const user = userEvent.setup();
renderLogin();
await typeCredentials(user, 'worker@solelog.local');
await user.click(screen.getByRole('button', { name: 'Inloggen' }));
await waitFor(() =>
expect(screen.getByText('Geen toegang — alleen beheerders.')).toBeInTheDocument(),
);
});
it('shows a generic error when the credentials are rejected', async () => {
mockedSignIn.mockRejectedValue(new ApiError(401, 'Inloggen mislukt'));
const user = userEvent.setup();
renderLogin();
await typeCredentials(user, 'admin@solelog.local');
await user.click(screen.getByRole('button', { name: 'Inloggen' }));
await waitFor(() => expect(screen.getByText('Inloggen mislukt')).toBeInTheDocument());
});
it('disables the submit button while the sign-in is pending', async () => {
let release!: () => void;
mockedSignIn.mockImplementation(
() =>
new Promise<void>((resolve) => {
release = () => {
setToken('tok');
resolve();
};
}),
);
mockedFetchMe.mockResolvedValue(admin);
const user = userEvent.setup();
renderLogin();
await typeCredentials(user, 'admin@solelog.local');
const button = screen.getByRole('button', { name: 'Inloggen' });
await user.click(button);
expect(button).toBeDisabled();
release();
await waitFor(() => expect(button).not.toBeDisabled());
});
it('clears a previous error on the next submit', async () => {
mockedSignIn.mockRejectedValueOnce(new ApiError(401, 'Inloggen mislukt'));
mockedFetchMe.mockResolvedValue(admin);
const user = userEvent.setup();
renderLogin();
await typeCredentials(user, 'admin@solelog.local');
const button = screen.getByRole('button', { name: 'Inloggen' });
await user.click(button);
await waitFor(() => expect(screen.getByText('Inloggen mislukt')).toBeInTheDocument());
// Second submit succeeds; the stale error is cleared.
await user.click(button);
await waitFor(() => expect(screen.queryByText('Inloggen mislukt')).not.toBeInTheDocument());
});
});
// Lifecycle (B-D1): worker credentials are rejected with the not-authorized message;
// admin credentials render the authed shell.
describe('admin-only gate lifecycle', () => {
beforeEach(() => {
mockedSignIn.mockReset();
mockedFetchMe.mockReset();
mockedSignIn.mockImplementation(async () => {
setToken('tok');
});
});
afterEach(() => {
clearToken();
vi.clearAllMocks();
});
it('rejects a worker with the not-authorized message, then admits an admin to the shell', async () => {
// First sign-in resolves a worker (rejected); the retry resolves an admin (admitted).
mockedFetchMe.mockResolvedValueOnce(worker).mockResolvedValue(admin);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const user = userEvent.setup();
render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);
// Login is shown; a worker attempt is rejected and the shell never renders.
await typeCredentials(user, 'worker@solelog.local');
await user.click(screen.getByRole('button', { name: 'Inloggen' }));
await waitFor(() =>
expect(screen.getByText('Geen toegang — alleen beheerders.')).toBeInTheDocument(),
);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
// An admin attempt flips the gate and renders the authed shell (Sidebar nav).
await user.clear(screen.getByLabelText('E-mailadres'));
await typeCredentials(user, 'admin@solelog.local');
await user.click(screen.getByRole('button', { name: 'Inloggen' }));
await waitFor(() => expect(screen.getByRole('navigation')).toBeInTheDocument());
expect(within(screen.getByRole('navigation')).getByText('Live')).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -6,6 +6,7 @@ import type { ReportResponse } from '@solelog/shared';
import Reports from './Reports';
import { apiFetch } from '../lib/api';
import * as reportsApi from '../api/reports';
import { allTime, dayEndISO, dayStartISO, thisMonth, thisWeek } from '../lib/date-range';
vi.mock('../lib/api', () => ({ apiFetch: vi.fn(), API_URL: 'http://test' }));
const mockApiFetch = vi.mocked(apiFetch);
@@ -127,4 +128,139 @@ describe('Reports', () => {
expect(arg.from).toBeTruthy();
expect(arg.to).toBeTruthy();
});
it('shows a loading state while the report is pending', async () => {
mockApiFetch.mockImplementation((path?: string) => {
if (path?.startsWith('/api/admin/report')) return new Promise(() => {}) as never; // never resolves
return Promise.resolve([] as never);
});
renderReports();
expect(await screen.findByText(/Laden/)).toBeInTheDocument();
});
it('shows an error message when the report fails to load', async () => {
mockApiFetch.mockImplementation((path?: string) => {
if (path?.startsWith('/api/admin/report')) return Promise.reject(new Error('boom'));
return Promise.resolve([] as never);
});
renderReports();
expect(await screen.findByText('Kon rapport niet laden.')).toBeInTheDocument();
});
it('renders zero totals and empty tables when the report has no data', async () => {
// An empty object exercises every `?? 0` / `?? []` defensive fallback.
mockApiFetch.mockImplementation((path?: string) => {
if (path?.startsWith('/api/admin/report')) return Promise.resolve({} as never);
return Promise.resolve([] as never);
});
renderReports();
const empties = await screen.findAllByText('Geen gegevens.');
expect(empties).toHaveLength(3);
const totals = document.querySelector('.reports-totals') as HTMLElement;
expect(totals).toHaveTextContent('00:00:00');
expect(totals).toHaveTextContent('0 zolen');
});
it('shows an export error message when the download fails', async () => {
mockEndpoints();
vi.spyOn(reportsApi, 'downloadExport').mockRejectedValue(new Error('nope'));
renderReports();
await screen.findByRole('cell', { name: 'Jan' });
await userEvent.click(screen.getByRole('button', { name: 'Exporteer CSV' }));
expect(await screen.findByText('Export mislukt. Probeer opnieuw.')).toBeInTheDocument();
});
it('lists activity options and includes the chosen activity in the query', async () => {
mockApiFetch.mockImplementation((path?: string) => {
if (path?.startsWith('/api/admin/report')) return Promise.resolve(REPORT as never);
if (path === '/api/admin/users')
return Promise.resolve([{ id: 'u1', name: 'Jan', email: 'jan@x' }] as never);
if (path === '/api/activities')
return Promise.resolve([
{ id: 7, name: 'Frezen', insole_types: ['Kurk'], sort_order: 0 },
] as never);
return Promise.resolve([] as never);
});
renderReports();
await screen.findByRole('cell', { name: 'Jan' });
await userEvent.selectOptions(screen.getByLabelText('Handeling'), '7');
await waitFor(() => {
const reportCalls = mockApiFetch.mock.calls.filter((c) =>
String(c[0]).startsWith('/api/admin/report'),
);
expect(String(reportCalls[reportCalls.length - 1][0])).toContain('activity_id=7');
});
});
it('lifecycle: "Deze maand" + a worker filter both reflect in the query and rows render', async () => {
mockEndpoints();
renderReports();
await screen.findByRole('cell', { name: 'Jan' });
await userEvent.click(screen.getByRole('button', { name: 'Deze maand' }));
await userEvent.selectOptions(screen.getByLabelText('Medewerker'), 'u1');
const expectedFrom = dayStartISO(thisMonth(new Date()).from);
await waitFor(() => {
const reportCalls = mockApiFetch.mock.calls.filter((c) =>
String(c[0]).startsWith('/api/admin/report'),
);
const last = String(reportCalls[reportCalls.length - 1][0]);
const params = new URLSearchParams(last.split('?')[1]);
expect(params.get('user_id')).toBe('u1');
expect(params.get('from')).toBe(expectedFrom);
});
// Returned rows still render after the filters change.
expect(screen.getByRole('cell', { name: 'Jan' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'Frezen' })).toBeInTheDocument();
});
it('applies the week/all presets and the type filter to the query', async () => {
mockEndpoints();
renderReports();
await screen.findByRole('cell', { name: 'Jan' });
const lastReportParams = () => {
const calls = mockApiFetch.mock.calls.filter((c) =>
String(c[0]).startsWith('/api/admin/report'),
);
return new URLSearchParams(String(calls[calls.length - 1][0]).split('?')[1]);
};
await userEvent.click(screen.getByRole('button', { name: 'Alles' }));
await waitFor(() =>
expect(lastReportParams().get('from')).toBe(dayStartISO(allTime(new Date()).from)),
);
await userEvent.click(screen.getByRole('button', { name: 'Deze week' }));
await waitFor(() =>
expect(lastReportParams().get('from')).toBe(dayStartISO(thisWeek(new Date()).from)),
);
await userEvent.selectOptions(screen.getByLabelText('Type'), 'Berk');
await waitFor(() => expect(lastReportParams().get('insole_type')).toBe('Berk'));
});
it('updates the query when the Van/Tot dates change', async () => {
mockEndpoints();
renderReports();
await screen.findByRole('cell', { name: 'Jan' });
fireEvent.change(screen.getByLabelText('Van'), { target: { value: '2026-06-10' } });
fireEvent.change(screen.getByLabelText('Tot'), { target: { value: '2026-06-12' } });
await waitFor(() => {
const calls = mockApiFetch.mock.calls.filter((c) =>
String(c[0]).startsWith('/api/admin/report'),
);
const params = new URLSearchParams(String(calls[calls.length - 1][0]).split('?')[1]);
expect(params.get('from')).toBe(dayStartISO('2026-06-10'));
expect(params.get('to')).toBe(dayEndISO('2026-06-12'));
});
});
});

View File

@@ -192,4 +192,188 @@ describe('Sessions', () => {
expect(within(list).queryByText('Piet')).not.toBeInTheDocument();
expect(within(list).queryByText('Klaas')).not.toBeInTheDocument();
});
it('shows the error state when the sessions query fails', async () => {
mockApiFetch.mockImplementation((path: string) => {
if (path === '/api/admin/sessions') return Promise.reject(new Error('boom'));
return Promise.resolve([] as never);
});
renderSessions();
expect(await screen.findByText('Kon sessies niet laden.')).toBeInTheDocument();
});
it('shows the in-filter empty message when the filter matches nothing', async () => {
mockEndpoints([makeSession({ id: 1, status: 'completed', user_name: 'Jan' })]);
renderSessions();
expect(await screen.findByText('Jan')).toBeInTheDocument();
await userEvent.selectOptions(screen.getByLabelText('Status'), 'active');
expect(screen.getByText('Geen sessies in dit filter.')).toBeInTheDocument();
});
it('falls back to placeholders for a row missing worker, activity, type and date', async () => {
const bare = makeSession({
id: 9,
user_name: undefined,
activity_name: undefined,
insole_type: null,
paused_seconds: 0,
status: 'completed',
start_time: 'not-a-real-date',
});
mockEndpoints([bare]);
renderSessions();
expect(await screen.findByText('Onbekend')).toBeInTheDocument();
expect(screen.getByText('Onbekende handeling')).toBeInTheDocument();
// aria-label uses the lowercase 'onbekend' fallback when user_name is absent.
expect(screen.getByRole('button', { name: 'Bewerk sessie van onbekend' })).toBeInTheDocument();
// no pause line when paused_seconds is 0, and no insole-type chip.
expect(screen.queryByText(/Pauze/)).not.toBeInTheDocument();
expect(screen.queryByText('Kurk')).not.toBeInTheDocument();
});
it('freezes worked time at the pause moment for a paused active session', async () => {
const pausedActive = makeSession({
id: 8,
status: 'active',
duration_seconds: null,
end_time: null,
start_time: new Date(Date.now() - 600_000).toISOString(), // started 10 min ago
paused_at: new Date(Date.now() - 300_000).toISOString(), // paused 5 min ago
paused_seconds: 60,
});
mockEndpoints([pausedActive]);
renderSessions();
// worked = (paused_at - start)/1000 - paused_seconds = 300 - 60 = 240s = 00:04:00
expect(await screen.findByText('00:04:00')).toBeInTheDocument();
});
it('✎ opens the edit form for that row', async () => {
mockEndpoints(SESSIONS);
renderSessions();
expect(await screen.findByText('Jan')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Bewerk sessie van Jan' }));
const form = await screen.findByTestId('session-form');
// Edit mode renders a Status select and no worker picker (create mode is the inverse).
expect(within(form).getByLabelText('Status')).toBeInTheDocument();
expect(within(form).queryByLabelText('Medewerker')).not.toBeInTheDocument();
});
it('cancelling the form returns to the list', async () => {
mockEndpoints(SESSIONS);
renderSessions();
await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe registratie' }));
expect(await screen.findByTestId('session-form')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Annuleren' }));
expect(screen.queryByTestId('session-form')).not.toBeInTheDocument();
});
});
// Lifecycle: create a manual session through the form → it lands in the list →
// edit it → the change persists. Uses a stateful mock that behaves like the backend.
const LC_USERS = [{ id: 'u1', name: 'Jan', email: 'jan@example.com' }];
const LC_ACTIVITIES = [
{ id: 10, name: 'Frezen', insole_types: ['Kurk', 'Berk', '3D'], created_at: '', sort_order: 0 },
{ id: 11, name: 'Lijmen', insole_types: ['Kurk', 'Berk', '3D'], created_at: '', sort_order: 1 },
];
describe('Sessions + SessionForm lifecycle', () => {
beforeEach(() => {
mockApiFetch.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
it('creates a manual session via the form, then persists an edit', async () => {
const store: WorkSession[] = [];
let nextId = 100;
mockApiFetch.mockImplementation((path: string, init?: RequestInit) => {
const method = init?.method ?? 'GET';
if (path === '/api/admin/users') return Promise.resolve(LC_USERS as never);
if (path === '/api/activities') return Promise.resolve(LC_ACTIVITIES as never);
if (path === '/api/admin/sessions' && method === 'GET') {
return Promise.resolve([...store] as never);
}
if (path === '/api/admin/sessions' && method === 'POST') {
const input = JSON.parse(init!.body as string);
const created = makeSession({
...input,
id: nextId++,
user_name: LC_USERS.find((u) => u.id === input.user_id)?.name,
activity_name: LC_ACTIVITIES.find((a) => a.id === input.activity_id)?.name,
duration_seconds:
(Date.parse(input.end_time) - Date.parse(input.start_time)) / 1000 -
input.paused_seconds,
status: 'completed',
});
store.push(created);
return Promise.resolve(created as never);
}
const put = path.match(/^\/api\/admin\/sessions\/(\d+)$/);
if (put && method === 'PUT') {
const id = Number(put[1]);
const input = JSON.parse(init!.body as string);
const idx = store.findIndex((s) => s.id === id);
store[idx] = {
...store[idx],
...input,
activity_name: LC_ACTIVITIES.find((a) => a.id === input.activity_id)?.name,
};
return Promise.resolve(store[idx] as never);
}
return Promise.resolve([] as never);
});
renderSessions();
// Starts empty.
expect(await screen.findByText('Nog geen sessies.')).toBeInTheDocument();
// Open create form and fill a valid manual session.
await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe registratie' }));
const createForm = await screen.findByTestId('session-form');
await within(createForm).findByRole('option', { name: 'Jan' });
await within(createForm).findByRole('option', { name: 'Frezen' });
await userEvent.selectOptions(within(createForm).getByLabelText('Medewerker'), 'u1');
await userEvent.selectOptions(within(createForm).getByLabelText('Handeling'), '10');
await userEvent.selectOptions(within(createForm).getByLabelText('Zooltype'), 'Kurk');
await userEvent.type(within(createForm).getByLabelText('Starttijd'), '2026-06-17T08:00');
await userEvent.type(within(createForm).getByLabelText('Eindtijd'), '2026-06-17T09:00');
await userEvent.click(within(createForm).getByRole('button', { name: /Opslaan/ }));
// Form closes and the new row appears in the list.
await waitFor(() => expect(screen.queryByTestId('session-form')).not.toBeInTheDocument());
const list = await screen.findByRole('list', { name: 'Sessies' });
expect(within(list).getByText('Jan')).toBeInTheDocument();
expect(within(list).getByText('Frezen')).toBeInTheDocument();
// Edit the row: change the activity Frezen → Lijmen.
await userEvent.click(screen.getByRole('button', { name: 'Bewerk sessie van Jan' }));
const editForm = await screen.findByTestId('session-form');
await within(editForm).findByRole('option', { name: 'Lijmen' });
await userEvent.selectOptions(within(editForm).getByLabelText('Handeling'), '11');
await userEvent.click(within(editForm).getByRole('button', { name: /Opslaan/ }));
// The change persists in the refreshed list.
await waitFor(() => expect(screen.queryByTestId('session-form')).not.toBeInTheDocument());
await waitFor(() =>
expect(
within(screen.getByRole('list', { name: 'Sessies' })).getByText('Lijmen'),
).toBeInTheDocument(),
);
expect(
within(screen.getByRole('list', { name: 'Sessies' })).queryByText('Frezen'),
).not.toBeInTheDocument();
});
});

View File

@@ -92,6 +92,16 @@ describe('Users', () => {
);
});
it('closes the create form when Annuleer is clicked', async () => {
mockEndpoints();
renderUsers();
await screen.findByText('Jan');
await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe gebruiker' }));
expect(screen.getByTestId('user-form')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Annuleer' }));
expect(screen.queryByTestId('user-form')).not.toBeInTheDocument();
});
it('Deactiveer on another user calls the deactivate endpoint', async () => {
mockEndpoints();
renderUsers();
@@ -104,4 +114,186 @@ describe('Users', () => {
),
);
});
it('shows an error state when the users query fails', async () => {
mockApiFetch.mockImplementation((path?: string) => {
if (path === '/api/admin/users') return Promise.reject(new Error('boom'));
return Promise.resolve({ user: USERS[0] } as never);
});
renderUsers();
expect(await screen.findByText('Kon gebruikers niet laden.')).toBeInTheDocument();
});
it('toggles another user to admin via the role button', async () => {
mockEndpoints();
renderUsers();
const janRow = (await screen.findByText('Jan')).closest('tr') as HTMLElement;
await userEvent.click(within(janRow).getByRole('button', { name: 'Maak admin' }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/users/u2/role',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ role: 'admin' }) }),
),
);
});
it('demotes another admin to worker via the role button', async () => {
const admin2: AdminUser = {
id: 'u4',
name: 'Bea',
email: 'bea@x',
role: 'admin',
status: 'active',
created_at: new Date('2026-06-04T00:00:00Z').toISOString(),
};
mockApiFetch.mockImplementation((path?: string, init?: RequestInit) => {
if (path === '/api/admin/users' && (!init || init.method === undefined))
return Promise.resolve([USERS[0], admin2] as never);
if (path === '/api/me') return Promise.resolve({ user: USERS[0] } as never);
return Promise.resolve({} as never);
});
renderUsers();
const beaRow = (await screen.findByText('Bea')).closest('tr') as HTMLElement;
await userEvent.click(within(beaRow).getByRole('button', { name: 'Maak werker' }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/users/u4/role',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ role: 'worker' }) }),
),
);
});
it('reactivates an inactive user', async () => {
mockEndpoints();
renderUsers();
const anRow = (await screen.findByText('An')).closest('tr') as HTMLElement;
await userEvent.click(within(anRow).getByRole('button', { name: 'Heractiveer' }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/users/u3/reactivate',
expect.objectContaining({ method: 'POST' }),
),
);
});
it('opens the inline password reset and posts the new password', async () => {
mockEndpoints();
renderUsers();
const janRow = (await screen.findByText('Jan')).closest('tr') as HTMLElement;
await userEvent.click(within(janRow).getByRole('button', { name: 'Reset wachtwoord' }));
await userEvent.type(within(janRow).getByLabelText('Nieuw wachtwoord voor Jan'), 'nieuw-1234');
await userEvent.click(within(janRow).getByRole('button', { name: 'Opslaan' }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/users/u2/password',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ password: 'nieuw-1234' }),
}),
),
);
});
it('keeps the reset save disabled until 8 chars, then cancels the inline reset', async () => {
mockEndpoints();
renderUsers();
const janRow = (await screen.findByText('Jan')).closest('tr') as HTMLElement;
await userEvent.click(within(janRow).getByRole('button', { name: 'Reset wachtwoord' }));
await userEvent.type(within(janRow).getByLabelText('Nieuw wachtwoord voor Jan'), 'short');
expect(within(janRow).getByRole('button', { name: 'Opslaan' })).toBeDisabled();
await userEvent.click(within(janRow).getByRole('button', { name: 'Annuleer' }));
expect(within(janRow).queryByLabelText('Nieuw wachtwoord voor Jan')).not.toBeInTheDocument();
});
it('surfaces an error when creating a user fails', async () => {
mockApiFetch.mockImplementation((path?: string, init?: RequestInit) => {
if (path === '/api/admin/users' && init?.method === 'POST')
return Promise.reject(new Error('dup'));
if (path === '/api/admin/users') return Promise.resolve(USERS as never);
if (path === '/api/me') return Promise.resolve({ user: USERS[0] } as never);
return Promise.resolve({} as never);
});
renderUsers();
await screen.findByText('Jan');
await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe gebruiker' }));
await userEvent.type(screen.getByLabelText('Naam'), 'Nieuw');
await userEvent.type(screen.getByLabelText('E-mail'), 'nieuw@x.nl');
await userEvent.type(screen.getByLabelText('Wachtwoord'), 'wachtwoord-123');
await userEvent.click(screen.getByRole('button', { name: 'Aanmaken' }));
expect(await screen.findByText('E-mailadres bestaat al of ongeldig.')).toBeInTheDocument();
});
// Lifecycle: create a worker via the form → appears in the list → promote to admin
// (reflected after refetch) → reset its password inline (field closes on success).
it('lifecycle: create → list → promote → reset password', async () => {
const list: AdminUser[] = [USERS[0]];
let nextId = 100;
mockApiFetch.mockImplementation((path?: string, init?: RequestInit) => {
const method = init?.method;
if (path === '/api/me') return Promise.resolve({ user: USERS[0] } as never);
if (path === '/api/admin/users' && !method) return Promise.resolve([...list] as never);
if (path === '/api/admin/users' && method === 'POST') {
const body = JSON.parse(init!.body as string);
const created: AdminUser = {
id: `u${nextId++}`,
name: body.name,
email: body.email,
role: body.role,
status: 'active',
created_at: new Date('2026-07-01T00:00:00Z').toISOString(),
};
list.push(created);
return Promise.resolve(created as never);
}
const roleMatch = path?.match(/^\/api\/admin\/users\/(.+)\/role$/);
if (roleMatch && method === 'POST') {
const body = JSON.parse(init!.body as string);
const u = list.find((x) => x.id === roleMatch[1])!;
u.role = body.role;
return Promise.resolve(u as never);
}
if (path?.endsWith('/password') && method === 'POST')
return Promise.resolve({ success: true } as never);
return Promise.resolve({} as never);
});
renderUsers();
await screen.findByRole('button', { name: '+ Nieuwe gebruiker' });
await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe gebruiker' }));
await userEvent.type(screen.getByLabelText('Naam'), 'Sanne');
await userEvent.type(screen.getByLabelText('E-mail'), 'sanne@x.nl');
await userEvent.type(screen.getByLabelText('Wachtwoord'), 'wachtwoord-1');
await userEvent.click(screen.getByRole('button', { name: 'Aanmaken' }));
const sanneRow = (await screen.findByText('Sanne')).closest('tr') as HTMLElement;
expect(within(sanneRow).getByText('Werker')).toBeInTheDocument();
await userEvent.click(within(sanneRow).getByRole('button', { name: 'Maak admin' }));
await waitFor(() => {
const row = screen.getByText('Sanne').closest('tr') as HTMLElement;
expect(within(row).getByText('Beheerder')).toBeInTheDocument();
});
const promoted = screen.getByText('Sanne').closest('tr') as HTMLElement;
await userEvent.click(within(promoted).getByRole('button', { name: 'Reset wachtwoord' }));
await userEvent.type(
within(promoted).getByLabelText('Nieuw wachtwoord voor Sanne'),
'geheim-1234',
);
await userEvent.click(within(promoted).getByRole('button', { name: 'Opslaan' }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
expect.stringMatching(/\/password$/),
expect.objectContaining({ method: 'POST' }),
),
);
await waitFor(() =>
expect(
within(screen.getByText('Sanne').closest('tr') as HTMLElement).queryByLabelText(
'Nieuw wachtwoord voor Sanne',
),
).not.toBeInTheDocument(),
);
});
});

View File

@@ -43,6 +43,7 @@ export default function Users() {
);
}
/* c8 ignore next -- defensive: isLoading/isError returned above, so data is always set here */
const users = usersQuery.data ?? [];
return (
@@ -96,6 +97,7 @@ function UserRow({ user, isSelf }: { user: AdminUser; isSelf: boolean }) {
setRole.isPending || deactivate.isPending || reactivate.isPending || resetPassword.isPending;
function onReset() {
/* c8 ignore next -- defensive: the Opslaan button is disabled while pw<8, so this never fires */
if (pw.length < 8) return;
resetPassword.mutate(
{ id: user.id, password: pw },

View File

@@ -3,5 +3,16 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'] },
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.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'],
},
},
});

View File

@@ -8,6 +8,7 @@
"start": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
@@ -23,6 +24,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@vitest/coverage-v8": "^3",
"drizzle-kit": "^0.30.1",
"tsx": "^4.19.2",
"typescript": "^5.7.2",

View File

@@ -15,6 +15,7 @@ export async function runMigrations(): Promise<void> {
// Allow running directly: `tsx src/db/migrate.ts` (cross-platform — pathToFileURL
// handles Windows drive-letter/backslash paths, which a raw `file://` prefix does not).
/* c8 ignore start -- CLI entry point; unreachable when imported by the test runner */
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
runMigrations()
.then(() => {
@@ -26,3 +27,4 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
process.exit(1);
});
}
/* c8 ignore stop */

View File

@@ -67,6 +67,7 @@ export async function seed(): Promise<void> {
// Allow running directly: `tsx src/db/seed.ts` (cross-platform — pathToFileURL
// handles Windows drive-letter/backslash paths, which a raw `file://` prefix does not).
/* c8 ignore start -- CLI entry point; unreachable when imported by the test runner */
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
seed()
.then(() => {
@@ -78,3 +79,4 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
process.exit(1);
});
}
/* c8 ignore stop */

View File

@@ -16,6 +16,7 @@ function toActivity(row: ActivityRow): Activity {
name: row.name,
insole_types: row.insoleTypes as Activity['insole_types'],
created_at: new Date(row.createdAt).toISOString(),
/* c8 ignore next -- sort_order is NOT NULL DEFAULT 0; the ?? fallback is unreachable */
sort_order: row.sortOrder ?? 0,
};
}

View File

@@ -86,6 +86,7 @@ adminUsersRoutes.post('/api/admin/users/:id/role', async (c) => {
return c.json({ error: 'Je kunt jezelf niet degraderen.' }, 400);
}
if (target.role === 'admin' && newRole !== 'admin') {
/* c8 ignore next 3 -- unreachable over HTTP: the self-demote guard above already handles caller===target, so any caller reaching here is a distinct active admin and activeAdminsExcluding(target) >= 1; the last-admin invariant is unit-tested via activeAdminsExcluding */
if ((await activeAdminsExcluding(id)) === 0) {
return c.json({ error: 'Er moet minstens één actieve beheerder blijven.' }, 400);
}
@@ -103,6 +104,7 @@ adminUsersRoutes.post('/api/admin/users/:id/deactivate', async (c) => {
if (!target) return c.json({ error: 'Gebruiker niet gevonden' }, 404);
if (id === caller?.id) return c.json({ error: 'Je kunt jezelf niet deactiveren.' }, 400);
/* c8 ignore next 3 -- unreachable over HTTP: the self-deactivate guard above ensures the caller is a distinct active admin, so activeAdminsExcluding(target) >= 1 and this last-admin branch never fires; the invariant is unit-tested via activeAdminsExcluding */
if (target.role === 'admin' && (await activeAdminsExcluding(id)) === 0) {
return c.json({ error: 'Er moet minstens één actieve beheerder blijven.' }, 400);
}

View File

@@ -152,6 +152,7 @@ adminRoutes.get('/api/admin/report', async (c) => {
for (const r of rows) {
const worked = r.session.durationSeconds ?? 0;
/* c8 ignore next 2 -- pausedSeconds & pairCount are NOT NULL (schema defaults); the ?? guards DB-impossible nulls */
const paused = r.session.pausedSeconds ?? 0;
const pairs = r.session.pairCount ?? 0;
@@ -163,6 +164,7 @@ adminRoutes.get('/api/admin/report', async (c) => {
const uid = r.session.userId;
const w = workers.get(uid) ?? {
user_id: uid,
/* c8 ignore next -- userId FK cascades on user delete, so a session's user always joins */
user_name: r.userName ?? 'Onbekend',
worked_seconds: 0,
paused_seconds: 0,
@@ -178,6 +180,7 @@ adminRoutes.get('/api/admin/report', async (c) => {
const aid = r.session.activityId;
const a = acts.get(aid) ?? {
activity_id: aid,
/* c8 ignore next -- activityId FK is enforced, so a session's activity always joins */
activity_name: r.activityName ?? 'Onbekend',
worked_seconds: 0,
paused_seconds: 0,
@@ -350,6 +353,7 @@ adminRoutes.post('/api/admin/sessions/:id/stop', async (c) => {
const extraPaused = row.pausedAt
? Math.round((now - new Date(row.pausedAt).getTime()) / 1000)
: 0;
/* c8 ignore next -- pausedSeconds is NOT NULL (schema default 0); the ?? guards a DB-impossible null */
const pausedSeconds = (row.pausedSeconds ?? 0) + extraPaused;
const durationSeconds = computeDuration(new Date(row.startTime).getTime(), now, pausedSeconds);

View File

@@ -136,6 +136,7 @@ sessionsRoutes.post('/api/sessions/:id/resume', async (c) => {
if (!row.pausedAt) return c.json({ error: 'Not paused' }, 409);
const pausedSeconds =
/* c8 ignore next -- pausedSeconds is NOT NULL (default 0) in schema; ?? 0 is a defensive guard */
(row.pausedSeconds ?? 0) + Math.round((Date.now() - new Date(row.pausedAt).getTime()) / 1000);
const [updated] = await db
@@ -164,6 +165,7 @@ sessionsRoutes.post('/api/sessions/:id/stop', async (c) => {
const extraPaused = row.pausedAt
? Math.round((now - new Date(row.pausedAt).getTime()) / 1000)
: 0;
/* c8 ignore next -- pausedSeconds is NOT NULL (default 0) in schema; ?? 0 is a defensive guard */
const pausedSeconds = (row.pausedSeconds ?? 0) + extraPaused;
const endTime = new Date(now);
const wall = Math.round((now - new Date(row.startTime).getTime()) / 1000);

View File

@@ -1,13 +1,15 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { createApp } from '../src/app';
import { db } from '../src/db/client';
import { workSessions, user } from '../src/db/schema';
import { eq } from 'drizzle-orm';
import { authToken, bearer } from './helpers';
import { authToken, bearer, resetDb } from './helpers';
const json = { 'content-type': 'application/json' };
describe('activities routes', () => {
beforeEach(resetDb);
it('401s GET /api/activities without a token', async () => {
const app = createApp();
const res = await app.request('/api/activities');
@@ -305,4 +307,144 @@ describe('activities routes', () => {
.where(eq(workSessions.activityId, created.id));
expect(after.length).toBe(0);
});
// --- write-route authz gating (401 no token / 403 worker) ---
it('401s PUT /api/activities/:id without a token', async () => {
const app = createApp();
const res = await app.request('/api/activities/1', {
method: 'PUT',
headers: json,
body: JSON.stringify({ name: 'X', insole_types: ['Kurk'] }),
});
expect(res.status).toBe(401);
});
it('forbids a worker from updating an activity (403)', async () => {
const app = createApp();
const token = await authToken(app, 'act-put-worker@example.com');
const res = await app.request('/api/activities/1', {
method: 'PUT',
headers: bearer(token),
body: JSON.stringify({ name: 'X', insole_types: ['Kurk'] }),
});
expect(res.status).toBe(403);
});
it('401s DELETE /api/activities/:id without a token', async () => {
const app = createApp();
const res = await app.request('/api/activities/1', { method: 'DELETE' });
expect(res.status).toBe(401);
});
it('forbids a worker from deleting an activity (403)', async () => {
const app = createApp();
const token = await authToken(app, 'act-delete-worker@example.com');
const res = await app.request('/api/activities/1', {
method: 'DELETE',
headers: bearer(token),
});
expect(res.status).toBe(403);
});
it('401s PUT /api/activities/reorder without a token', async () => {
const app = createApp();
const res = await app.request('/api/activities/reorder', {
method: 'PUT',
headers: json,
body: JSON.stringify({ ids: [1] }),
});
expect(res.status).toBe(401);
});
// --- id parsing + body validation on the write routes ---
it('404s PUT for a non-numeric id', async () => {
const app = createApp();
const token = await authToken(app, 'act-put-nan@example.com', 'admin');
const res = await app.request('/api/activities/not-a-number', {
method: 'PUT',
headers: bearer(token),
body: JSON.stringify({ name: 'X', insole_types: ['Kurk'] }),
});
expect(res.status).toBe(404);
});
it('400s PUT with an invalid body (empty name)', async () => {
const app = createApp();
const token = await authToken(app, 'act-put-invalid@example.com', 'admin');
const createRes = await app.request('/api/activities', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ name: 'Bewerkbaar', insole_types: ['Kurk'] }),
});
const created = await createRes.json();
const res = await app.request(`/api/activities/${created.id}`, {
method: 'PUT',
headers: bearer(token),
body: JSON.stringify({ name: ' ', insole_types: ['Kurk'] }),
});
expect(res.status).toBe(400);
});
it('404s DELETE for a non-numeric id', async () => {
const app = createApp();
const token = await authToken(app, 'act-delete-nan@example.com', 'admin');
const res = await app.request('/api/activities/not-a-number', {
method: 'DELETE',
headers: bearer(token),
});
expect(res.status).toBe(404);
});
it('400s reorder with an empty ids array', async () => {
const app = createApp();
const token = await authToken(app, 'act-reorder-empty@example.com', 'admin');
const res = await app.request('/api/activities/reorder', {
method: 'PUT',
headers: bearer(token),
body: JSON.stringify({ ids: [] }),
});
expect(res.status).toBe(400);
});
it('400s reorder when the ids count matches but contains an unknown id', async () => {
const app = createApp();
const token = await authToken(app, 'act-reorder-unknown@example.com', 'admin');
const createRes = await app.request('/api/activities', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ name: 'Enige', insole_types: ['Kurk'] }),
});
const created = await createRes.json();
// Same length as the full set (1), but the id is not a known activity.
const res = await app.request('/api/activities/reorder', {
method: 'PUT',
headers: bearer(token),
body: JSON.stringify({ ids: [created.id + 999] }),
});
expect(res.status).toBe(400);
});
it('round-trips insole_types as a stored subset', async () => {
const app = createApp();
const token = await authToken(app, 'act-subset@example.com', 'admin');
const createRes = await app.request('/api/activities', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ name: 'Subset', insole_types: ['Berk'] }),
});
const created = await createRes.json();
expect(created.insole_types).toEqual(['Berk']);
const listRes = await app.request('/api/activities', { headers: bearer(token) });
const list: Array<{ id: number; insole_types: string[] }> = await listRes.json();
const found = list.find((a) => a.id === created.id);
expect(found?.insole_types).toEqual(['Berk']);
});
});

View File

@@ -1,10 +1,11 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import type { Hono } from 'hono';
import { createApp } from '../src/app';
import { authToken, bearer } from './helpers';
import { db } from '../src/db/client';
import { user } from '../src/db/schema';
import { eq } from 'drizzle-orm';
import { auth } from '../src/auth';
import { activeAdminsExcluding } from '../src/routes/admin-users';
const PASSWORD = 'sterk-wachtwoord-123';
@@ -391,4 +392,90 @@ describe('POST /api/admin/users/:id/password', () => {
).status,
).toBe(403);
});
it('404s on an unknown id', async () => {
const app = createApp();
const adminTok = await authToken(app, 'pw-404-admin@example.com', 'admin');
const res = await app.request('/api/admin/users/does-not-exist/password', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ password: 'long-enough-123' }),
});
expect(res.status).toBe(404);
expect((await res.json()).error).toContain('niet gevonden');
});
// When better-auth's setUserPassword throws, the route must surface a 400 (not a 500).
it('400s when better-auth setUserPassword throws', async () => {
const app = createApp();
const adminTok = await authToken(app, 'pw-fail-admin@example.com', 'admin');
await authToken(app, 'pw-fail-target@example.com');
const id = await userIdByEmail('pw-fail-target@example.com');
const spy = vi.spyOn(auth.api, 'setUserPassword').mockRejectedValueOnce(new Error('boom'));
try {
const res = await app.request(`/api/admin/users/${id}/password`, {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ password: 'long-enough-123' }),
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('Wachtwoord wijzigen mislukt.');
} finally {
spy.mockRestore();
}
});
});
// Gap-fill: branches not exercised by the suites above (invalid input, not-found paths,
// and the null-role list fallback in toListItem).
describe('admin-users — validation & not-found gaps', () => {
it('lists a user with a null DB role as a worker (toListItem fallback)', async () => {
const app = createApp();
const adminTok = await authToken(app, 'nullrole-admin@example.com', 'admin');
await authToken(app, 'nullrole-target@example.com');
const id = await userIdByEmail('nullrole-target@example.com');
await db.update(user).set({ role: null }).where(eq(user.id, id));
const res = await app.request('/api/admin/users', { headers: bearer(adminTok) });
const list = (await res.json()) as Array<{ id: string; role: string }>;
expect(list.find((u) => u.id === id)?.role).toBe('worker');
});
it('400s the role route on an invalid role body', async () => {
const app = createApp();
const adminTok = await authToken(app, 'role-invalid-admin@example.com', 'admin');
await authToken(app, 'role-invalid-target@example.com');
const id = await userIdByEmail('role-invalid-target@example.com');
const res = await app.request(`/api/admin/users/${id}/role`, {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ role: 'superuser' }),
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('Invalid input');
});
it('404s the role route on an unknown id', async () => {
const app = createApp();
const adminTok = await authToken(app, 'role-404-admin@example.com', 'admin');
const res = await app.request('/api/admin/users/does-not-exist/role', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ role: 'admin' }),
});
expect(res.status).toBe(404);
expect((await res.json()).error).toContain('niet gevonden');
});
it('404s the reactivate route on an unknown id', async () => {
const app = createApp();
const adminTok = await authToken(app, 'react-404-admin@example.com', 'admin');
const res = await app.request('/api/admin/users/does-not-exist/reactivate', {
method: 'POST',
headers: bearer(adminTok),
});
expect(res.status).toBe(404);
expect((await res.json()).error).toContain('niet gevonden');
});
});

View File

@@ -1,8 +1,9 @@
import { describe, it, expect } from 'vitest';
import type { Hono } from 'hono';
import { createApp } from '../src/app';
import { authToken, bearer, createTestUser, seedActivity } from './helpers';
import { db } from '../src/db/client';
import { user } from '../src/db/schema';
import { user, workSessions } from '../src/db/schema';
import { eq } from 'drizzle-orm';
async function userIdByEmail(email: string): Promise<string> {
@@ -11,6 +12,29 @@ async function userIdByEmail(email: string): Promise<string> {
return row.id;
}
// Create a 1h completed manual session and return its row (used to exercise the edit path).
async function seedManualSession(
app: Hono,
adminTok: string,
targetId: string,
activityId: number,
): Promise<{ id: number }> {
return (
await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
}),
})
).json();
}
describe('admin session views', () => {
it('401s without a token', async () => {
const app = createApp();
@@ -182,6 +206,40 @@ describe('admin manual session create', () => {
expect(res.status).toBe(404);
});
it('400s when the body fails schema validation', async () => {
const app = createApp();
const adminTok = await authToken(app, 'create-badbody-admin@example.com', 'admin');
const res = await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ pair_count: 0 }), // missing required fields + invalid pair_count
});
expect(res.status).toBe(400);
});
it('400s when paused_seconds exceeds the worked span', async () => {
const app = createApp();
const adminTok = await authToken(app, 'create-pausedbig-admin@example.com', 'admin');
await createTestUser('create-pausedbig-target@example.com');
const targetId = await userIdByEmail('create-pausedbig-target@example.com');
const activityId = await seedActivity('Frezen');
const res = await app.request('/api/admin/sessions', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
user_id: targetId,
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 1,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z', // 3600s span
paused_seconds: 4000, // > span
}),
});
expect(res.status).toBe(400);
});
it('403s for a worker', async () => {
const app = createApp();
const workerTok = await authToken(app, 'create-worker@example.com'); // worker
@@ -330,6 +388,112 @@ describe('admin session edit', () => {
expect(res.status).toBe(400);
});
it('404s for a non-numeric session id', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-nan-admin@example.com', 'admin');
const res = await app.request('/api/admin/sessions/not-a-number', {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({
activity_id: 1,
insole_type: 'Kurk',
pair_count: 1,
start_time: '2026-06-17T08:00:00.000Z',
end_time: null,
paused_seconds: 0,
notes: null,
status: 'active',
}),
});
expect(res.status).toBe(404);
});
it('400s when the body fails schema validation', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-badbody-admin@example.com', 'admin');
const res = await app.request('/api/admin/sessions/1', {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({ pair_count: 0 }),
});
expect(res.status).toBe(400);
});
it('400s when start_time is not a valid date', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-badstart-admin@example.com', 'admin');
await createTestUser('edit-badstart-target@example.com');
const targetId = await userIdByEmail('edit-badstart-target@example.com');
const activityId = await seedActivity('Frezen');
const created = await seedManualSession(app, adminTok, targetId, activityId);
const res = await app.request(`/api/admin/sessions/${created.id}`, {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: 'not-a-date',
end_time: null,
paused_seconds: 0,
notes: null,
status: 'active',
}),
});
expect(res.status).toBe(400);
});
it('400s when paused_seconds exceeds the span on edit', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-pausedbig-admin@example.com', 'admin');
await createTestUser('edit-pausedbig-target@example.com');
const targetId = await userIdByEmail('edit-pausedbig-target@example.com');
const activityId = await seedActivity('Lijmen');
const created = await seedManualSession(app, adminTok, targetId, activityId);
const res = await app.request(`/api/admin/sessions/${created.id}`, {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({
activity_id: activityId,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z', // 3600s span
paused_seconds: 5000, // > span
notes: null,
status: 'completed',
}),
});
expect(res.status).toBe(400);
});
it('404s for an unknown activity on edit', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-noact-admin@example.com', 'admin');
await createTestUser('edit-noact-target@example.com');
const targetId = await userIdByEmail('edit-noact-target@example.com');
const activityId = await seedActivity('Snijden');
const created = await seedManualSession(app, adminTok, targetId, activityId);
const res = await app.request(`/api/admin/sessions/${created.id}`, {
method: 'PUT',
headers: bearer(adminTok),
body: JSON.stringify({
activity_id: 999999,
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-06-17T08:00:00.000Z',
end_time: '2026-06-17T09:00:00.000Z',
paused_seconds: 0,
notes: null,
status: 'completed',
}),
});
expect(res.status).toBe(404);
});
it('404s for an unknown session', async () => {
const app = createApp();
const adminTok = await authToken(app, 'edit-404-admin@example.com', 'admin');
@@ -429,6 +593,106 @@ describe('admin stop/discard another user session', () => {
expect(res.status).toBe(409);
});
it('404s for a non-numeric or unknown stop id', async () => {
const app = createApp();
const adminTok = await authToken(app, 'stop-404-admin@example.com', 'admin');
expect(
(
await app.request('/api/admin/sessions/not-a-number/stop', {
method: 'POST',
headers: bearer(adminTok),
})
).status,
).toBe(404);
expect(
(
await app.request('/api/admin/sessions/999999/stop', {
method: 'POST',
headers: bearer(adminTok),
})
).status,
).toBe(404);
});
it('folds an open pause into paused_seconds when stopping', async () => {
const app = createApp();
const adminTok = await authToken(app, 'stop-paused-admin@example.com', 'admin');
const workerTok = await authToken(app, 'stop-paused-worker@example.com'); // worker
const activityId = await seedActivity('Frezen');
const started = await (
await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
})
).json();
// Back-date the start (20s) and leave an open pause (6s ago) so the admin stop
// folds the running pause into paused_seconds and excludes it from worked time.
await db
.update(workSessions)
.set({ startTime: new Date(Date.now() - 20000), pausedAt: new Date(Date.now() - 6000) })
.where(eq(workSessions.id, started.id));
const res = await app.request(`/api/admin/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('completed');
expect(body.paused_at).toBeNull();
expect(body.paused_seconds).toBeGreaterThanOrEqual(6); // the open pause was folded in
expect(body.duration_seconds).toBeLessThan(20); // paused time excluded from worked
});
it('404s for a non-numeric or unknown discard id', async () => {
const app = createApp();
const adminTok = await authToken(app, 'discard-404-admin@example.com', 'admin');
expect(
(
await app.request('/api/admin/sessions/not-a-number/discard', {
method: 'POST',
headers: bearer(adminTok),
})
).status,
).toBe(404);
expect(
(
await app.request('/api/admin/sessions/999999/discard', {
method: 'POST',
headers: bearer(adminTok),
})
).status,
).toBe(404);
});
it('409s when discarding an already-closed session', async () => {
const app = createApp();
const adminTok = await authToken(app, 'discard-closed-admin@example.com', 'admin');
const workerTok = await authToken(app, 'discard-closed-worker@example.com'); // worker
const activityId = await seedActivity('Snijden');
const started = await (
await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
})
).json();
await app.request(`/api/admin/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(adminTok),
});
const res = await app.request(`/api/admin/sessions/${started.id}/discard`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(res.status).toBe(409);
});
it('403s for a worker hitting stop/discard', async () => {
const app = createApp();
const workerTok = await authToken(app, 'stop-worker-gate@example.com'); // worker

View File

@@ -9,6 +9,17 @@ describe('auth', () => {
expect(token).toBeTruthy();
});
it('rejects sign-in with a wrong password', async () => {
const app = createApp();
await authToken(app, 'wrong-pw@example.com'); // create the user first
const res = await app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'wrong-pw@example.com', password: 'not-the-password' }),
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('rejects public sign-up (admin creates users)', async () => {
const app = createApp();
const res = await app.request('/api/auth/sign-up/email', {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildSessionsCsv, type SessionCsvRow } from '../src/lib/csv';
import { buildSessionsCsv, quote, formatDuration, type SessionCsvRow } from '../src/lib/csv';
const row: SessionCsvRow = {
id: 7,
@@ -31,4 +31,52 @@ describe('buildSessionsCsv', () => {
);
expect(lines[1].startsWith('"Jan"')).toBe(true);
});
it('applies legacy fallbacks for null fields and blanks a missing end time', () => {
// Every nullable field null (+ pairCount null via cast, since the type is non-null
// but the code still carries a legacy `?? 2` default). includeWorker exercises the
// userName fallback too. Covers each `??` fallback branch and the null-end-time branch.
const emptyRow = {
id: 1,
activityName: null,
userName: null,
insoleType: null,
pairCount: null,
startTime: new Date('2026-06-17T08:00:00Z'),
endTime: null,
durationSeconds: null,
pausedSeconds: null,
} as unknown as SessionCsvRow;
const cells = buildSessionsCsv([emptyRow], { includeWorker: true }).split('\n')[1].split(',');
expect(cells[0]).toBe('""'); // Worker → ''
expect(cells[1]).toBe('"1"'); // ID
expect(cells[2]).toBe('""'); // Task → ''
expect(cells[3]).toBe('"Kurk"'); // Insole Type → legacy default
expect(cells[4]).toBe('"2"'); // No. of Insoles → legacy default
expect(cells[6]).toBe('"00:00:00"'); // Total Duration → 0
expect(cells[7]).toBe('"00:00:00"'); // Paused Duration → 0
expect(cells[9]).toBe('""'); // End Time → '' (null end)
});
it('escapes commas, quotes and newlines inside a field', () => {
const tricky: SessionCsvRow = { ...row, activityName: 'Frezen, "spoed"\n2e regel' };
const csv = buildSessionsCsv([tricky]);
// Comma stays inside the quoted cell, embedded quotes are doubled, newline is preserved.
expect(csv).toContain('"Frezen, ""spoed""\n2e regel"');
});
});
describe('csv helpers (edges)', () => {
it('quote wraps and escapes commas, quotes and newlines', () => {
expect(quote('a,b')).toBe('"a,b"');
expect(quote('a"b')).toBe('"a""b"');
expect(quote('a\nb')).toBe('"a\nb"');
});
it('formatDuration renders zero and lets hours exceed 99', () => {
expect(formatDuration(0)).toBe('00:00:00');
expect(formatDuration(3661)).toBe('01:01:01');
expect(formatDuration(360000)).toBe('100:00:00');
});
});

View File

@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import { db } from '../src/db/client';
import { user } from '../src/db/schema';
import { runMigrations } from '../src/db/migrate';
import { ensureDbDir } from '../src/db/ensure-dir';
describe('database', () => {
it('can query the migrated user table (empty)', async () => {
@@ -8,3 +10,21 @@ describe('database', () => {
expect(rows).toEqual([]);
});
});
describe('runMigrations', () => {
it('is idempotent — running again on an already-migrated DB does not throw', async () => {
// setup.ts already migrated the test DB once; re-running must be a no-op.
await expect(runMigrations()).resolves.toBeUndefined();
await expect(runMigrations()).resolves.toBeUndefined();
});
});
describe('ensureDbDir', () => {
it('is a no-op for a non-file: URL', () => {
expect(() => ensureDbDir('libsql://remote.example')).not.toThrow();
});
it('does not create a directory for a bare filename in the current dir', () => {
expect(() => ensureDbDir('file:app.db')).not.toThrow();
});
});

60
apps/api/test/env.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
// env.ts reads process.env once at module-eval time, so each case re-imports it
// under a controlled environment (resetModules busts the cached module), then
// restores the original process.env immediately so nothing leaks to other tests.
const KEYS = ['DATABASE_URL', 'BETTER_AUTH_SECRET', 'BETTER_AUTH_URL', 'PORT', 'CORS_ORIGINS'];
async function loadEnv(overrides: Record<string, string | undefined>) {
const saved: Record<string, string | undefined> = {};
for (const k of KEYS) saved[k] = process.env[k];
for (const k of KEYS) delete process.env[k];
for (const [k, v] of Object.entries(overrides)) if (v !== undefined) process.env[k] = v;
vi.resetModules();
try {
const { env } = await import('../src/env');
return env;
} finally {
for (const k of KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
}
}
describe('env', () => {
afterEach(() => vi.resetModules());
it('applies every dev default when the vars are unset', async () => {
const env = await loadEnv({});
expect(env.DATABASE_URL).toBe('file:./data/app.db');
expect(env.BETTER_AUTH_SECRET).toBe('dev-insecure-secret-change-me');
expect(env.BETTER_AUTH_URL).toBe('http://localhost:3000');
expect(env.PORT).toBe(3000);
expect(env.WEB_ORIGINS).toEqual(['http://localhost:5173', 'http://localhost:5174']);
});
it('reads each override when the var is set (PORT coerced to a number)', async () => {
const env = await loadEnv({
DATABASE_URL: 'file:./other.db',
BETTER_AUTH_SECRET: 'top-secret',
BETTER_AUTH_URL: 'https://api.example.com',
PORT: '8080',
});
expect(env.DATABASE_URL).toBe('file:./other.db');
expect(env.BETTER_AUTH_SECRET).toBe('top-secret');
expect(env.BETTER_AUTH_URL).toBe('https://api.example.com');
expect(env.PORT).toBe(8080);
expect(typeof env.PORT).toBe('number');
});
it('parses CORS_ORIGINS into WEB_ORIGINS, trimming and dropping empty entries', async () => {
const env = await loadEnv({ CORS_ORIGINS: ' http://a.example , http://b.example , ' });
expect(env.WEB_ORIGINS).toEqual(['http://a.example', 'http://b.example']);
});
it('falls back to the default origins when CORS_ORIGINS is empty', async () => {
const env = await loadEnv({ CORS_ORIGINS: '' });
expect(env.WEB_ORIGINS).toEqual(['http://localhost:5173', 'http://localhost:5174']);
});
});

View File

@@ -1,11 +1,11 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import type { Hono } from 'hono';
import { createApp } from '../src/app';
import { db } from '../src/db/client';
import { workSessions } from '../src/db/schema';
import { eq } from 'drizzle-orm';
import { quote, formatDuration } from '../src/lib/csv';
import { authToken, bearer, seedActivity } from './helpers';
import { authToken, bearer, seedActivity, resetDb } from './helpers';
// Start a session, backdate its start_time by `durationSeconds`, then stop it,
// producing a completed session with an exact duration.
@@ -31,6 +31,8 @@ async function completedSession(
}
describe('csv export', () => {
beforeEach(resetDb);
it('401s without a token', async () => {
const app = createApp();
const res = await app.request('/api/export');
@@ -118,6 +120,32 @@ describe('csv export', () => {
const lines = (await res.text()).split('\n');
expect(lines).toHaveLength(2); // header + 1 completed row
});
it('orders rows by start time ascending', async () => {
const app = createApp();
const token = await authToken(app, 'export-order@example.com');
const frezenId = await seedActivity('Frezen');
const slijpenId = await seedActivity('Slijpen');
// Two completed sessions; make Frezen clearly the older start.
const older = await completedSession(app, token, frezenId, 'Kurk', 30);
const newer = await completedSession(app, token, slijpenId, 'Berk', 30);
await db
.update(workSessions)
.set({ startTime: new Date(Date.now() - 60000) })
.where(eq(workSessions.id, older));
await db
.update(workSessions)
.set({ startTime: new Date(Date.now() - 10000) })
.where(eq(workSessions.id, newer));
const lines = (
await (await app.request('/api/export', { headers: bearer(token) })).text()
).split('\n');
expect(lines).toHaveLength(3); // header + 2 rows
expect(lines[1]).toContain('"Frezen"'); // oldest start first
expect(lines[2]).toContain('"Slijpen"');
});
});
describe('csv helpers', () => {

View File

@@ -1,7 +1,7 @@
import type { Hono } from 'hono';
import { auth } from '../src/auth';
import { db } from '../src/db/client';
import { activities } from '../src/db/schema';
import { account, activities, session, user, verification, workSessions } from '../src/db/schema';
const PASSWORD = 'sterk-wachtwoord-123';
const json = { 'content-type': 'application/json' };
@@ -44,6 +44,18 @@ export function bearer(token: string): Record<string, string> {
return { authorization: `Bearer ${token}`, 'content-type': 'application/json' };
}
// Wipe all data rows (schema stays; migration already ran once in setup.ts).
// Lets a domain suite start from an empty DB in beforeEach instead of inheriting
// rows from earlier test files (removes the accumulate-and-scope fragility).
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);
}
// Insert an activity straight into the DB (test setup that should not depend on authz).
export async function seedActivity(
name: string,

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createApp } from '../../src/app';
import { authToken, bearer, resetDb } from '../helpers';
describe('activities lifecycle (admin create → reorder → worker sees order → delete)', () => {
beforeEach(resetDb);
it('creates, reorders, reflects the order to a worker, then removes one', async () => {
const app = createApp();
const adminToken = await authToken(app, 'act-lc-admin@example.com', 'admin');
const workerToken = await authToken(app, 'act-lc-worker@example.com');
// Admin creates three activities; they append in creation order.
const names = ['Zagen', 'Frezen', 'Slijpen'];
const created: Array<{ id: number; name: string }> = [];
for (const name of names) {
const res = await app.request('/api/activities', {
method: 'POST',
headers: bearer(adminToken),
body: JSON.stringify({ name, insole_types: ['Kurk'] }),
});
expect(res.status).toBe(200);
created.push(await res.json());
}
expect(created.map((a) => a.name)).toEqual(names);
// Admin reverses the order.
const reversedIds = created.map((a) => a.id).reverse();
const reorderRes = await app.request('/api/activities/reorder', {
method: 'PUT',
headers: bearer(adminToken),
body: JSON.stringify({ ids: reversedIds }),
});
expect(reorderRes.status).toBe(200);
// A worker reads the list and sees the new order (sorted by sort_order).
const workerListRes = await app.request('/api/activities', { headers: bearer(workerToken) });
expect(workerListRes.status).toBe(200);
const workerList: Array<{ id: number; name: string; sort_order: number }> =
await workerListRes.json();
expect(workerList.map((a) => a.id)).toEqual(reversedIds);
expect(workerList.map((a) => a.name)).toEqual(['Slijpen', 'Frezen', 'Zagen']);
for (let i = 1; i < workerList.length; i++) {
expect(workerList[i].sort_order).toBeGreaterThanOrEqual(workerList[i - 1].sort_order);
}
// Admin deletes the middle activity; it disappears from the list.
const toDelete = created[1]; // Frezen
const delRes = await app.request(`/api/activities/${toDelete.id}`, {
method: 'DELETE',
headers: bearer(adminToken),
});
expect(delRes.status).toBe(200);
expect(await delRes.json()).toEqual({ success: true });
const afterRes = await app.request('/api/activities', { headers: bearer(workerToken) });
const after: Array<{ id: number }> = await afterRes.json();
expect(after.map((a) => a.id)).toEqual([created[2].id, created[0].id]);
expect(after.some((a) => a.id === toDelete.id)).toBe(false);
});
});

View File

@@ -0,0 +1,68 @@
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';
const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;
describe('admin↔worker session lifecycle (worker starts → admin stops → report + export)', () => {
beforeEach(resetDb);
it('an admin-stopped worker session lands in the report and export with correct totals', async () => {
const app = createApp();
const adminTok = await authToken(app, 'admin-ws-admin@example.com', 'admin');
const workerTok = await authToken(app, 'admin-ws-worker@example.com'); // worker
const activityId = await seedActivity('Frezen', ['Kurk']);
// Worker starts a session.
const started = await (
await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 3 }),
})
).json();
// Back-date the start so the admin stop yields a deterministic-ish worked span.
await db
.update(workSessions)
.set({ startTime: new Date(Date.now() - 120000) })
.where(eq(workSessions.id, started.id));
// Admin stops it via the admin endpoint.
const stopRes = await app.request(`/api/admin/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(stopRes.status).toBe(200);
const stopped = await stopRes.json();
expect(stopped.status).toBe('completed');
expect(stopped.duration_seconds).toBeGreaterThanOrEqual(120);
// The session shows up in the admin report with totals matching the stored row.
const report = await (
await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) })
).json();
expect(report.totals.sessions).toBe(1);
expect(report.totals.worked_seconds).toBe(stopped.duration_seconds);
expect(report.totals.pairs).toBe(3);
expect(report.by_worker).toHaveLength(1);
expect(report.by_worker[0].user_name).toBe('admin-ws-worker');
expect(report.by_worker[0].worked_seconds).toBe(stopped.duration_seconds);
expect(report.by_activity).toHaveLength(1);
expect(report.by_activity[0].activity_name).toBe('Frezen');
expect(report.by_type).toHaveLength(1);
expect(report.by_type[0].insole_type).toBe('Kurk');
// And in the all-users export CSV (header + the one row, with the Worker column).
const csvRes = await app.request(`/api/admin/export?${WIDE}`, { headers: bearer(adminTok) });
expect(csvRes.status).toBe(200);
const lines = (await csvRes.text()).trim().split('\n');
expect(lines).toHaveLength(2);
expect(lines[0]).toContain('"Worker"');
expect(lines[1]).toContain('admin-ws-worker');
expect(lines[1]).toContain('Frezen');
});
});

View File

@@ -0,0 +1,32 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createApp } from '../../src/app';
import { authToken, bearer, resetDb } from '../helpers';
describe('auth lifecycle (create worker → sign in → /api/me → admin route 403)', () => {
beforeEach(resetDb);
it('issues a bearer that authorizes /api/me as a worker but not admin routes', async () => {
const app = createApp();
const token = await authToken(app, 'auth-lifecycle@example.com', 'worker');
// /api/me returns the full worker identity for the freshly-issued token.
const meRes = await app.request('/api/me', { headers: bearer(token) });
expect(meRes.status).toBe(200);
const { user } = await meRes.json();
expect(user).toEqual({
id: expect.any(String),
email: 'auth-lifecycle@example.com',
name: 'auth-lifecycle',
role: 'worker',
});
// The same token is rejected by an admin-only route (worker is not an admin).
const adminRes = await app.request('/api/admin/users', { headers: bearer(token) });
expect(adminRes.status).toBe(403);
// The bearer keeps working across repeated requests (stateless token, not one-shot).
for (let i = 0; i < 3; i++) {
expect((await app.request('/api/me', { headers: bearer(token) })).status).toBe(200);
}
});
});

View File

@@ -0,0 +1,162 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { eq } from 'drizzle-orm';
import { createApp } from '../../src/app';
import { db } from '../../src/db/client';
import { user, workSessions } from '../../src/db/schema';
import { authToken, bearer, seedActivity, resetDb } from '../helpers';
const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;
async function userIdByEmail(email: string): Promise<string> {
const [u] = await db.select({ id: user.id }).from(user).where(eq(user.email, email));
return u.id;
}
// Insert a completed session with exact, known metrics — no timing, fully deterministic.
async function seedCompleted(opts: {
userId: string;
activityId: number;
insoleType: string;
worked: number;
paused: number;
pairs: number;
}): Promise<void> {
const start = new Date('2026-03-01T08:00:00.000Z');
await db.insert(workSessions).values({
userId: opts.userId,
activityId: opts.activityId,
insoleType: opts.insoleType,
pairCount: opts.pairs,
startTime: start,
endTime: new Date(start.getTime() + (opts.worked + opts.paused) * 1000),
durationSeconds: opts.worked,
pausedSeconds: opts.paused,
status: 'completed',
source: 'manual',
});
}
describe('report aggregation lifecycle (2 workers × 2 activities → exact totals + breakdowns)', () => {
beforeEach(resetDb);
it('reports exact totals, breakdowns sorted by worked desc, and narrows on a worker filter', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-agg-life-admin@example.com', 'admin');
await authToken(app, 'report-agg-life-w1@example.com');
await authToken(app, 'report-agg-life-w2@example.com');
const w1 = await userIdByEmail('report-agg-life-w1@example.com');
const w2 = await userIdByEmail('report-agg-life-w2@example.com');
const frezen = await seedActivity('Frezen');
const lijmen = await seedActivity('Lijmen');
// W1: Frezen/Kurk 300s, Lijmen/Berk 100s. W2: Frezen/Kurk 200s, Lijmen/Berk 50s.
await seedCompleted({
userId: w1,
activityId: frezen,
insoleType: 'Kurk',
worked: 300,
paused: 30,
pairs: 2,
});
await seedCompleted({
userId: w1,
activityId: lijmen,
insoleType: 'Berk',
worked: 100,
paused: 10,
pairs: 1,
});
await seedCompleted({
userId: w2,
activityId: frezen,
insoleType: 'Kurk',
worked: 200,
paused: 20,
pairs: 3,
});
await seedCompleted({
userId: w2,
activityId: lijmen,
insoleType: 'Berk',
worked: 50,
paused: 5,
pairs: 1,
});
const body = await (
await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) })
).json();
// Totals sum every session.
expect(body.totals).toEqual({ worked_seconds: 650, paused_seconds: 65, pairs: 7, sessions: 4 });
// by_worker: W1 (400) before W2 (250) — sorted worked desc.
expect(body.by_worker.map((w: { user_name: string }) => w.user_name)).toEqual([
'report-agg-life-w1',
'report-agg-life-w2',
]);
expect(body.by_worker[0]).toMatchObject({
user_id: w1,
worked_seconds: 400,
paused_seconds: 40,
pairs: 3,
sessions: 2,
});
expect(body.by_worker[1]).toMatchObject({
user_id: w2,
worked_seconds: 250,
paused_seconds: 25,
pairs: 4,
sessions: 2,
});
// by_activity: Frezen (500) before Lijmen (150).
expect(body.by_activity.map((a: { activity_name: string }) => a.activity_name)).toEqual([
'Frezen',
'Lijmen',
]);
expect(body.by_activity[0]).toMatchObject({
activity_id: frezen,
worked_seconds: 500,
pairs: 5,
sessions: 2,
});
expect(body.by_activity[1]).toMatchObject({
activity_id: lijmen,
worked_seconds: 150,
pairs: 2,
sessions: 2,
});
// by_type: Kurk (500) before Berk (150).
expect(body.by_type.map((t: { insole_type: string }) => t.insole_type)).toEqual([
'Kurk',
'Berk',
]);
expect(body.by_type[0]).toMatchObject({
insole_type: 'Kurk',
worked_seconds: 500,
pairs: 5,
sessions: 2,
});
expect(body.by_type[1]).toMatchObject({
insole_type: 'Berk',
worked_seconds: 150,
pairs: 2,
sessions: 2,
});
// A worker filter narrows totals and breakdowns to W1 only.
const narrowed = await (
await app.request(`/api/admin/report?${WIDE}&user_id=${w1}`, { headers: bearer(adminTok) })
).json();
expect(narrowed.totals).toEqual({
worked_seconds: 400,
paused_seconds: 40,
pairs: 3,
sessions: 2,
});
expect(narrowed.by_worker).toHaveLength(1);
expect(narrowed.by_worker[0].user_id).toBe(w1);
});
});

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { eq } from 'drizzle-orm';
import { createApp } from '../../src/app';
import { db } from '../../src/db/client';
import { session, user } from '../../src/db/schema';
import { authToken, bearer, resetDb } from '../helpers';
const PASSWORD = 'sterk-wachtwoord-123';
async function signIn(app: ReturnType<typeof createApp>, email: string) {
return app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password: PASSWORD }),
});
}
describe('admin user lifecycle (create → sign in → deactivate → reactivate)', () => {
beforeEach(resetDb);
it('deactivate revokes the live token and blocks sign-in; reactivate restores both', async () => {
const app = createApp();
const adminTok = await authToken(app, 'lifecycle-admin@example.com', 'admin');
// admin creates a worker
const createRes = await app.request('/api/admin/users', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
email: 'lifecycle-worker@example.com',
name: 'Lifecycle Worker',
password: PASSWORD,
role: 'worker',
}),
});
expect(createRes.status).toBe(200);
const created = await createRes.json();
expect(created.status).toBe('active');
// worker signs in → the issued bearer authorizes /api/me
const firstSignIn = await signIn(app, 'lifecycle-worker@example.com');
expect(firstSignIn.status).toBe(200);
const workerTok = firstSignIn.headers.get('set-auth-token');
if (!workerTok) throw new Error('no worker token');
expect((await app.request('/api/me', { headers: bearer(workerTok) })).status).toBe(200);
// admin deactivates the worker
const off = await app.request(`/api/admin/users/${created.id}/deactivate`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(off.status).toBe(200);
expect((await off.json()).status).toBe('inactive');
// deactivate deleted the session rows → the pre-issued token no longer authorizes /api/me
expect(await db.select().from(session).where(eq(session.userId, created.id))).toHaveLength(0);
expect((await app.request('/api/me', { headers: bearer(workerTok) })).status).toBe(401);
// and a fresh sign-in is blocked while banned
expect((await signIn(app, 'lifecycle-worker@example.com')).status).not.toBe(200);
// admin reactivates → ban cleared, sign-in works again
const on = await app.request(`/api/admin/users/${created.id}/reactivate`, {
method: 'POST',
headers: bearer(adminTok),
});
expect((await on.json()).status).toBe('active');
const [row] = await db.select().from(user).where(eq(user.id, created.id));
expect(row.banned).toBeFalsy();
expect((await signIn(app, 'lifecycle-worker@example.com')).status).toBe(200);
});
});

View File

@@ -0,0 +1,69 @@
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
});
});

View File

@@ -1,8 +1,11 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { createApp } from '../src/app';
import { auth } from '../src/auth';
import { authToken, bearer } from './helpers';
describe('GET /api/me', () => {
afterEach(() => vi.restoreAllMocks());
it('rejects an unauthenticated request', async () => {
const app = createApp();
const res = await app.request('/api/me');
@@ -39,4 +42,24 @@ describe('GET /api/me', () => {
const body = await res.json();
expect(body.user.role).toBe('admin');
});
it("defaults role to 'worker' when the session user has no role", async () => {
const app = createApp();
// A role-less session can't arise from a real sign-in (the admin plugin always
// sets a role), so stub getSession to hit the `?? 'worker'` fallback branch.
vi.spyOn(auth.api, 'getSession').mockResolvedValue({
user: { id: 'u1', email: 'norole@example.com', name: 'No Role' },
session: {},
} as never);
const res = await app.request('/api/me', { headers: bearer('irrelevant') });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.user).toEqual({
id: 'u1',
email: 'norole@example.com',
name: 'No Role',
role: 'worker',
});
});
});

View File

@@ -1,10 +1,10 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import type { Hono } from 'hono';
import { createApp } from '../src/app';
import { db } from '../src/db/client';
import { workSessions } from '../src/db/schema';
import { user, workSessions } from '../src/db/schema';
import { eq } from 'drizzle-orm';
import { authToken, bearer, seedActivity } from './helpers';
import { authToken, bearer, seedActivity, resetDb } from './helpers';
const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;
@@ -35,6 +35,8 @@ async function completed(
}
describe('GET /api/admin/report', () => {
beforeEach(resetDb);
it('401s without a token and 403s for a worker', async () => {
const app = createApp();
expect((await app.request(`/api/admin/report?${WIDE}`)).status).toBe(401);
@@ -132,9 +134,114 @@ describe('GET /api/admin/report', () => {
expect(body.by_activity).toEqual([]);
expect(body.by_type).toEqual([]);
});
it('400s on an invalid insole_type or a non-numeric activity_id', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-badfilter@example.com', 'admin');
expect(
(
await app.request(`/api/admin/report?${WIDE}&insole_type=Plastic`, {
headers: bearer(adminTok),
})
).status,
).toBe(400);
expect(
(
await app.request(`/api/admin/report?${WIDE}&activity_id=abc`, {
headers: bearer(adminTok),
})
).status,
).toBe(400);
});
it('narrows by the activity filter', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-actfilter-admin@example.com', 'admin');
const worker = await authToken(app, 'report-actfilter-worker@example.com');
const frezen = await seedActivity('Frezen');
const lijmen = await seedActivity('Lijmen');
await completed(app, worker, frezen, 'Kurk', 100, 2);
await completed(app, worker, lijmen, 'Berk', 40, 2);
const body = await (
await app.request(`/api/admin/report?${WIDE}&activity_id=${frezen}`, {
headers: bearer(adminTok),
})
).json();
expect(body.totals.sessions).toBe(1);
expect(body.totals.worked_seconds).toBe(100);
expect(body.by_activity).toHaveLength(1);
expect(body.by_activity[0].activity_id).toBe(frezen);
});
it('buckets a session with a null insole_type under "Onbekend"', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-nulltype-admin@example.com', 'admin');
await authToken(app, 'report-nulltype-worker@example.com');
const [w] = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, 'report-nulltype-worker@example.com'));
const act = await seedActivity('Frezen');
await db.insert(workSessions).values({
userId: w.id,
activityId: act,
insoleType: null,
pairCount: 2,
startTime: new Date('2026-03-01T08:00:00.000Z'),
endTime: new Date('2026-03-01T08:05:00.000Z'),
durationSeconds: 300,
pausedSeconds: 0,
status: 'completed',
source: 'manual',
});
const body = await (
await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) })
).json();
// Only the type bucket falls back; the worker/activity are real.
expect(body.by_type).toHaveLength(1);
expect(body.by_type[0].insole_type).toBe('Onbekend');
expect(body.by_type[0].worked_seconds).toBe(300);
expect(body.by_worker[0].user_name).toBe('report-nulltype-worker');
expect(body.by_activity[0].activity_name).toBe('Frezen');
});
it('counts a completed session with a null duration as 0 worked seconds', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-nulldur-admin@example.com', 'admin');
await authToken(app, 'report-nulldur-worker@example.com');
const [w] = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, 'report-nulldur-worker@example.com'));
const act = await seedActivity('Frezen');
// A completed row whose duration was never computed (e.g. admin edit with no end time).
await db.insert(workSessions).values({
userId: w.id,
activityId: act,
insoleType: 'Kurk',
pairCount: 2,
startTime: new Date('2026-03-01T08:00:00.000Z'),
endTime: null,
durationSeconds: null,
pausedSeconds: 0,
status: 'completed',
source: 'manual',
});
const body = await (
await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) })
).json();
expect(body.totals.sessions).toBe(1);
expect(body.totals.worked_seconds).toBe(0); // null duration counts as 0
expect(body.by_worker[0].worked_seconds).toBe(0);
});
});
describe('GET /api/admin/export', () => {
beforeEach(resetDb);
it('401s without a token and 403s for a worker', async () => {
const app = createApp();
expect((await app.request(`/api/admin/export?${WIDE}`)).status).toBe(401);
@@ -143,6 +250,18 @@ describe('GET /api/admin/export', () => {
expect(res.status).toBe(403);
});
it('400s on a missing or inverted range', async () => {
const app = createApp();
const adminTok = await authToken(app, 'export-badquery-admin@example.com', 'admin');
expect((await app.request('/api/admin/export', { headers: bearer(adminTok) })).status).toBe(
400,
);
const inverted = `from=${new Date('2026-02-01').toISOString()}&to=${new Date('2026-01-01').toISOString()}`;
expect(
(await app.request(`/api/admin/export?${inverted}`, { headers: bearer(adminTok) })).status,
).toBe(400);
});
it('exports all workers with a leading Worker column, range in the filename', async () => {
const app = createApp();
const adminTok = await authToken(app, 'export-admin@example.com', 'admin');
@@ -152,9 +271,8 @@ describe('GET /api/admin/export', () => {
await completed(app, a, act, 'Kurk', 90, 2);
await completed(app, b, act, 'Berk', 60, 2);
// Scope to this test's own activity so the all-users export isolates from the
// sessions other tests seed into the shared, run-scoped DB.
const res = await app.request(`/api/admin/export?${WIDE}&activity_id=${act}`, {
// resetDb isolates this test, so the unfiltered export must contain exactly both workers.
const res = await app.request(`/api/admin/export?${WIDE}`, {
headers: bearer(adminTok),
});
expect(res.status).toBe(200);

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import type { Context } from 'hono';
import { auth } from '../src/auth';
import { getSessionUser, isAdmin } from '../src/lib/require-user';
// getSessionUser/isAdmin are the auth gate's building blocks. The role/banned
// fallbacks can't be reached through a real sign-in (the admin plugin always sets
// a role), so exercise them by stubbing the one thing getSessionUser reads:
// auth.api.getSession. vi is already the suite's mocking tool (see env.test.ts).
const fakeCtx = { req: { raw: { headers: new Headers() } } } as unknown as Context;
function mockSession(sessionUser: Record<string, unknown> | null) {
vi.spyOn(auth.api, 'getSession').mockResolvedValue(
(sessionUser ? { user: sessionUser, session: {} } : null) as never,
);
}
describe('getSessionUser', () => {
afterEach(() => vi.restoreAllMocks());
it('returns null when there is no session', async () => {
mockSession(null);
expect(await getSessionUser(fakeCtx)).toBeNull();
});
it('maps id, role and banned from the session user', async () => {
mockSession({ id: 'u1', role: 'admin', banned: true });
expect(await getSessionUser(fakeCtx)).toEqual({ id: 'u1', role: 'admin', banned: true });
});
it("defaults role to 'worker' and banned to false when they are unset", async () => {
mockSession({ id: 'u2' });
expect(await getSessionUser(fakeCtx)).toEqual({ id: 'u2', role: 'worker', banned: false });
});
});
describe('isAdmin', () => {
it('is true only for the admin role', () => {
expect(isAdmin({ id: 'a', role: 'admin', banned: false })).toBe(true);
expect(isAdmin({ id: 'w', role: 'worker', banned: false })).toBe(false);
expect(isAdmin(null)).toBe(false);
});
});

View File

@@ -0,0 +1,21 @@
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);
});
});

View File

@@ -1,8 +1,9 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { inArray, eq } from 'drizzle-orm';
import { seed } from '../src/db/seed';
import { db } from '../src/db/client';
import { activities, user } from '../src/db/schema';
import { resetDb } from './helpers';
const SEED_NAMES = ['Leerrand', 'Frezen', 'Slijpen', 'Bekleden', 'Afwerken', 'Printen'];
@@ -37,3 +38,27 @@ describe('seed', () => {
);
});
});
describe('seed — production guard', () => {
beforeEach(resetDb);
it('seeds reference activities but never the dev users when NODE_ENV=production', async () => {
const prev = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
try {
await seed();
} finally {
if (prev === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = prev;
}
const devUsers = await db
.select()
.from(user)
.where(inArray(user.email, ['worker@solelog.local', 'admin@solelog.local']));
expect(devUsers).toHaveLength(0);
const acts = await db.select().from(activities).where(inArray(activities.name, SEED_NAMES));
expect(acts.length).toBe(SEED_NAMES.length);
});
});

View File

@@ -1,13 +1,15 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { createApp } from '../src/app';
import { db } from '../src/db/client';
import { workSessions } from '../src/db/schema';
import { eq } from 'drizzle-orm';
import { authToken, bearer, seedActivity } from './helpers';
import { authToken, bearer, seedActivity, resetDb } from './helpers';
const json = { 'content-type': 'application/json' };
describe('session lifecycle', () => {
beforeEach(resetDb);
it('401s start/stop/discard without a token', async () => {
const app = createApp();
@@ -334,6 +336,8 @@ describe('session lifecycle', () => {
});
describe('session reads', () => {
beforeEach(resetDb);
it('401s GET /api/sessions and /api/sessions/active without a token', async () => {
const app = createApp();
@@ -437,3 +441,146 @@ describe('session reads', () => {
expect(body[0].status).toBe('active');
});
});
describe('session edge and error branches', () => {
beforeEach(resetDb);
it('400s start when the body is not valid JSON', async () => {
const app = createApp();
const token = await authToken(app, 'sess-badjson@example.com');
const res = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: '{ not json',
});
expect(res.status).toBe(400);
});
it('404s pause/resume/stop/discard when the id is not numeric', async () => {
const app = createApp();
const token = await authToken(app, 'sess-nan-id@example.com');
for (const action of ['pause', 'resume', 'stop', 'discard']) {
const res = await app.request(`/api/sessions/not-a-number/${action}`, {
method: 'POST',
headers: bearer(token),
});
expect(res.status).toBe(404);
}
});
it('409s pausing a session that is no longer active', async () => {
const app = createApp();
const token = await authToken(app, 'sess-pause-closed@example.com');
const activityId = await seedActivity('Frezen');
const startRes = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
});
const started = await startRes.json();
await app.request(`/api/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(token),
});
const res = await app.request(`/api/sessions/${started.id}/pause`, {
method: 'POST',
headers: bearer(token),
});
expect(res.status).toBe(409);
});
it('409s resuming a session that is no longer active', async () => {
const app = createApp();
const token = await authToken(app, 'sess-resume-closed@example.com');
const activityId = await seedActivity('Slijpen');
const startRes = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
});
const started = await startRes.json();
await app.request(`/api/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(token),
});
const res = await app.request(`/api/sessions/${started.id}/resume`, {
method: 'POST',
headers: bearer(token),
});
expect(res.status).toBe(409);
});
it('404s discarding a missing session', async () => {
const app = createApp();
const token = await authToken(app, 'sess-discard-missing@example.com');
const res = await app.request('/api/sessions/999999/discard', {
method: 'POST',
headers: bearer(token),
});
expect(res.status).toBe(404);
});
it('409s discarding an already-closed session', async () => {
const app = createApp();
const token = await authToken(app, 'sess-discard-closed@example.com');
const activityId = await seedActivity('Bekleden');
const startRes = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
});
const started = await startRes.json();
await app.request(`/api/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(token),
});
const res = await app.request(`/api/sessions/${started.id}/discard`, {
method: 'POST',
headers: bearer(token),
});
expect(res.status).toBe(409);
});
it('404s stopping a missing session', async () => {
const app = createApp();
const token = await authToken(app, 'sess-stop-missing@example.com');
const res = await app.request('/api/sessions/999999/stop', {
method: 'POST',
headers: bearer(token),
});
expect(res.status).toBe(404);
});
it("does not let user B discard user A's session (ownership scoping → 404)", async () => {
const app = createApp();
const tokenA = await authToken(app, 'sess-discard-ownerA@example.com');
const tokenB = await authToken(app, 'sess-discard-ownerB@example.com');
const activityId = await seedActivity('Printen');
const startRes = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(tokenA),
body: JSON.stringify({ activity_id: activityId, insole_type: '3D', pair_count: 2 }),
});
const started = await startRes.json();
const res = await app.request(`/api/sessions/${started.id}/discard`, {
method: 'POST',
headers: bearer(tokenB),
});
expect(res.status).toBe(404);
const [row] = await db.select().from(workSessions).where(eq(workSessions.id, started.id));
expect(row.status).toBe('active');
});
});

View File

@@ -37,3 +37,71 @@ describe('toWorkSession paused fields', () => {
expect(result.paused_at).toBe(pausedAt.toISOString());
});
});
describe('toWorkSession field mapping', () => {
it('maps a null insoleType to null', () => {
const result = toWorkSession(baseRow({ insoleType: null }));
expect(result.insole_type).toBeNull();
});
it('coalesces a nullish pausedSeconds to 0 (defensive against a null column value)', () => {
const result = toWorkSession(baseRow({ pausedSeconds: null as unknown as number }));
expect(result.paused_seconds).toBe(0);
});
it('coalesces a nullish durationSeconds to null and a nullish notes to null', () => {
const result = toWorkSession(baseRow({ durationSeconds: null, notes: null }));
expect(result.duration_seconds).toBeNull();
expect(result.notes).toBeNull();
});
it('shapes every field, threading activity/user opts through', () => {
const start = new Date('2026-06-17T08:00:00.000Z');
const end = new Date('2026-06-17T09:30:00.000Z');
const created = new Date('2026-06-17T07:59:00.000Z');
const result = toWorkSession(
baseRow({
id: 42,
userId: 'user-42',
activityId: 7,
insoleType: 'Berk',
pairCount: 4,
startTime: start,
endTime: end,
durationSeconds: 5000,
pausedSeconds: 400,
status: 'completed',
source: 'manual',
notes: 'handmatig',
createdAt: created,
}),
{ activityName: 'Frezen', userName: 'Jan', userEmail: 'jan@example.com' },
);
expect(result).toEqual({
id: 42,
user_id: 'user-42',
activity_id: 7,
activity_name: 'Frezen',
user_name: 'Jan',
user_email: 'jan@example.com',
insole_type: 'Berk',
pair_count: 4,
start_time: start.toISOString(),
end_time: end.toISOString(),
duration_seconds: 5000,
paused_seconds: 400,
paused_at: null,
status: 'completed',
source: 'manual',
notes: 'handmatig',
created_at: created.toISOString(),
});
});
it('defaults activity/user opts to undefined when omitted', () => {
const result = toWorkSession(baseRow());
expect(result.activity_name).toBeUndefined();
expect(result.user_name).toBeUndefined();
expect(result.user_email).toBeUndefined();
});
});

View File

@@ -5,5 +5,13 @@ export default defineConfig({
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'],
},
},
});

View File

@@ -9,7 +9,8 @@
"preview": "vite preview",
"typecheck": "tsc -b",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@solelog/shared": "workspace:*",
@@ -26,6 +27,7 @@
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^3",
"jsdom": "^25.0.0",
"typescript": "^5.7.2",
"vite": "^7.0.0",

View File

@@ -42,4 +42,20 @@ describe('App', () => {
expect(tabbar.getByText('Geschiedenis')).toBeInTheDocument();
expect(tabbar.getByText('Account')).toBeInTheDocument();
});
it('lifecycle: unauthenticated renders Login (no nav); authenticated renders Stopwatch under the tab bar', () => {
// No token -> the Login screen, and no tab bar navigation.
clearToken();
const { unmount } = renderApp();
expect(screen.getByText('E-mailadres')).toBeInTheDocument();
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
unmount();
// Token present -> the Stopwatch screen is mounted at "/" beside the tab bar.
setToken('tok');
renderApp();
expect(screen.getByText('Type zool')).toBeInTheDocument();
expect(within(screen.getByRole('navigation')).getByText('Stopwatch')).toBeInTheDocument();
expect(screen.queryByText('E-mailadres')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { apiFetch } from '../lib/api';
import { useActivities } from './activities';
vi.mock('../lib/api', () => ({ apiFetch: vi.fn().mockResolvedValue([]) }));
const mockedApiFetch = vi.mocked(apiFetch);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
{children}
</QueryClientProvider>
);
describe('useActivities', () => {
it('queries GET /api/activities', async () => {
const { result } = renderHook(() => useActivities(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiFetch).toHaveBeenCalledWith('/api/activities');
});
});

View File

@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { apiFetch } from '../lib/api';
import { useMe } from './me';
vi.mock('../lib/api', () => ({
apiFetch: vi.fn().mockResolvedValue({ user: { id: 'u1', email: 'a@b.c', name: 'A' } }),
}));
const mockedApiFetch = vi.mocked(apiFetch);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
{children}
</QueryClientProvider>
);
describe('useMe', () => {
it('queries GET /api/me', async () => {
const { result } = renderHook(() => useMe(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiFetch).toHaveBeenCalledWith('/api/me');
});
});

View File

@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { apiFetch } from '../lib/api';
import {
useActiveSessions,
useDiscardSession,
usePauseSession,
useResumeSession,
useSessions,
useStartSession,
useStopSession,
} from './sessions';
vi.mock('../lib/api', () => ({ apiFetch: vi.fn() }));
const mockedApiFetch = vi.mocked(apiFetch);
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
describe('sessions api hooks', () => {
beforeEach(() => {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mockedApiFetch.mockResolvedValue([]);
});
afterEach(() => {
vi.clearAllMocks();
});
it('useSessions queries GET /api/sessions', async () => {
const { result } = renderHook(() => useSessions(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiFetch).toHaveBeenCalledWith('/api/sessions');
});
it('useActiveSessions queries GET /api/sessions/active', async () => {
const { result } = renderHook(() => useActiveSessions(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiFetch).toHaveBeenCalledWith('/api/sessions/active');
});
it('useStartSession POSTs the serialized input and invalidates the sessions cache', async () => {
const invalidate = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useStartSession(), { wrapper });
await result.current.mutateAsync({ activity_id: 1, insole_type: 'Kurk', pair_count: 3 });
expect(mockedApiFetch).toHaveBeenCalledWith('/api/sessions/start', {
method: 'POST',
body: JSON.stringify({ activity_id: 1, insole_type: 'Kurk', pair_count: 3 }),
});
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['sessions'] });
});
const idHooks: Array<[string, () => ReturnType<typeof useStopSession>, string]> = [
['useStopSession', useStopSession, 'stop'],
['useDiscardSession', useDiscardSession, 'discard'],
['usePauseSession', usePauseSession, 'pause'],
['useResumeSession', useResumeSession, 'resume'],
];
it.each(idHooks)(
'%s POSTs to /api/sessions/:id/%s and invalidates the sessions cache',
async (_name, hook, action) => {
const invalidate = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => hook(), { wrapper });
await result.current.mutateAsync(42);
expect(mockedApiFetch).toHaveBeenCalledWith(`/api/sessions/42/${action}`, { method: 'POST' });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['sessions'] });
},
);
});

View File

@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthProvider, useAuth } from './AuthContext';
import { signIn as apiSignIn } from '../lib/api';
import { clearToken, setToken } from '../lib/auth-storage';
// The sign-in network call is irrelevant here; stub it so nothing hits the network.
@@ -9,6 +10,8 @@ vi.mock('../lib/api', () => ({
signIn: vi.fn(),
}));
const mockedSignIn = vi.mocked(apiSignIn);
function SignOutButton() {
const { signOut } = useAuth();
return (
@@ -18,6 +21,18 @@ function SignOutButton() {
);
}
function AuthProbe() {
const { isAuthed, signIn } = useAuth();
return (
<>
<span data-testid="authed">{String(isAuthed)}</span>
<button type="button" onClick={() => void signIn('a@b.c', 'pw')}>
Inloggen
</button>
</>
);
}
describe('AuthContext signOut', () => {
beforeEach(() => {
clearToken();
@@ -47,3 +62,62 @@ describe('AuthContext signOut', () => {
expect(window.location.pathname).toBe('/');
});
});
describe('AuthContext signIn + mount', () => {
beforeEach(() => {
clearToken();
mockedSignIn.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
it('starts unauthenticated when no token is stored', () => {
render(
<AuthProvider>
<AuthProbe />
</AuthProvider>,
);
expect(screen.getByTestId('authed')).toHaveTextContent('false');
});
it('restores the authenticated state from a stored token on mount', () => {
setToken('tok');
render(
<AuthProvider>
<AuthProbe />
</AuthProvider>,
);
expect(screen.getByTestId('authed')).toHaveTextContent('true');
});
it('signIn delegates to the api and flips to authenticated', async () => {
mockedSignIn.mockResolvedValue(undefined);
const user = userEvent.setup();
render(
<AuthProvider>
<AuthProbe />
</AuthProvider>,
);
expect(screen.getByTestId('authed')).toHaveTextContent('false');
await user.click(screen.getByRole('button', { name: 'Inloggen' }));
await waitFor(() => expect(screen.getByTestId('authed')).toHaveTextContent('true'));
expect(mockedSignIn).toHaveBeenCalledWith('a@b.c', 'pw');
});
});
describe('useAuth guard', () => {
it('throws when used outside an AuthProvider', () => {
function Bare() {
useAuth();
return null;
}
// Silence React's expected error boundary logging for this render.
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<Bare />)).toThrow('useAuth must be used within an AuthProvider');
spy.mockRestore();
});
});

View File

@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import TabBar from './TabBar';
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<TabBar />
</MemoryRouter>,
);
}
describe('TabBar', () => {
it('renders the three tabs as links to their routes', () => {
renderAt('/');
expect(screen.getByRole('link', { name: 'Stopwatch' })).toHaveAttribute('href', '/');
expect(screen.getByRole('link', { name: 'Geschiedenis' })).toHaveAttribute('href', '/history');
expect(screen.getByRole('link', { name: 'Account' })).toHaveAttribute('href', '/account');
});
it('marks the tab of the current route active and leaves the others inactive', () => {
renderAt('/history');
expect(screen.getByRole('link', { name: 'Geschiedenis' })).toHaveClass('tab-active');
expect(screen.getByRole('link', { name: 'Stopwatch' })).not.toHaveClass('tab-active');
expect(screen.getByRole('link', { name: 'Account' })).not.toHaveClass('tab-active');
});
it('uses end matching so the Stopwatch tab is active only on the root path', () => {
renderAt('/');
expect(screen.getByRole('link', { name: 'Stopwatch' })).toHaveClass('tab-active');
});
});

View File

@@ -34,6 +34,36 @@ describe('api client', () => {
await expect(apiFetch('/api/me')).rejects.toBeInstanceOf(ApiError);
});
it('returns undefined when the response body is empty', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 200 })));
await expect(apiFetch('/api/sessions/1/stop', { method: 'POST' })).resolves.toBeUndefined();
});
it('sets Content-Type to application/json when a body is present and none is set', async () => {
const mock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', mock);
await apiFetch('/api/sessions/start', { method: 'POST', body: JSON.stringify({ a: 1 }) });
const [, init] = mock.mock.calls[0];
expect(new Headers(init.headers).get('Content-Type')).toBe('application/json');
});
it('does not override an explicitly provided Content-Type header', async () => {
const mock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', mock);
await apiFetch('/x', {
method: 'POST',
body: 'raw',
headers: { 'Content-Type': 'text/plain' },
});
const [, init] = mock.mock.calls[0];
expect(new Headers(init.headers).get('Content-Type')).toBe('text/plain');
});
it('signIn stores the token from the set-auth-token header', async () => {
vi.stubGlobal(
'fetch',

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { downloadExport } from './export';
import { setToken } from './auth-storage';
describe('downloadExport', () => {
beforeEach(() => {
localStorage.clear();
// jsdom does not implement the object-URL API; stub it so the download path runs.
URL.createObjectURL = vi.fn(() => 'blob:mock-url');
URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('fetches the CSV with the bearer token and triggers an object-URL download', async () => {
setToken('tok');
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const fetchMock = vi.fn().mockResolvedValue(new Response('a,b\n1,2\n', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await downloadExport();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://localhost:3000/api/export');
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer tok');
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
it('sends no Authorization header when there is no token', async () => {
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const fetchMock = vi.fn().mockResolvedValue(new Response('x', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await downloadExport();
const [, init] = fetchMock.mock.calls[0];
expect(new Headers(init.headers).has('Authorization')).toBe(false);
});
it('throws when the response is not ok', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })));
await expect(downloadExport()).rejects.toThrow('Kan de export niet openen');
});
});

View File

@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Account from './Account';
import App from '../App';
import { AuthProvider } from '../auth/AuthContext';
import { apiFetch } from '../lib/api';
import { clearToken, getToken, setToken } from '../lib/auth-storage';
@@ -25,6 +26,15 @@ function renderAccount() {
);
}
function renderApp() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);
}
describe('Account', () => {
beforeEach(() => {
mockedApiFetch.mockReset();
@@ -44,6 +54,13 @@ describe('Account', () => {
expect(screen.getByText('worker@solelog.local')).toBeInTheDocument();
});
it('shows the fallback message when the account cannot be loaded', async () => {
mockedApiFetch.mockResolvedValue({});
renderAccount();
expect(await screen.findByText('Kon accountgegevens niet laden.')).toBeInTheDocument();
expect(screen.queryByText('Test Werker')).not.toBeInTheDocument();
});
it('logs out: clicking Uitloggen clears the token', async () => {
setToken('tok');
const user = userEvent.setup();
@@ -51,4 +68,21 @@ describe('Account', () => {
await user.click(screen.getByRole('button', { name: 'Uitloggen' }));
expect(getToken()).toBeNull();
});
it('lifecycle: logging out returns to the login screen', async () => {
setToken('tok');
window.history.replaceState(null, '', '/account');
const user = userEvent.setup();
renderApp();
// On the Account screen while authenticated.
expect(await screen.findByRole('button', { name: 'Uitloggen' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Uitloggen' }));
// Context flips to unauthenticated → the login form renders.
expect(await screen.findByText('E-mailadres')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Uitloggen' })).not.toBeInTheDocument();
expect(getToken()).toBeNull();
});
});

View File

@@ -120,4 +120,80 @@ describe('History', () => {
await user.click(screen.getByRole('button', { name: 'Exporteer CSV' }));
expect(mockedDownloadExport).toHaveBeenCalledTimes(1);
});
it('shows an error message when the export fails', async () => {
const user = userEvent.setup();
mockedDownloadExport.mockRejectedValueOnce(new Error('boom'));
renderHistory();
await user.click(screen.getByRole('button', { name: 'Exporteer CSV' }));
expect(await screen.findByText(/Kan de export niet openen/)).toBeInTheDocument();
});
it('formats a sub-minute duration as just seconds', async () => {
mockedApiFetch.mockResolvedValue([session({ duration_seconds: 45 })]);
renderHistory();
const card = (await screen.findByText('Frezen')).closest('.session-card') as HTMLElement;
expect(card.textContent).toContain('45s');
expect(card.textContent).not.toContain('m 45s');
});
it('renders 0s when duration_seconds is null', async () => {
mockedApiFetch.mockResolvedValue([session({ duration_seconds: null })]);
renderHistory();
const card = (await screen.findByText('Frezen')).closest('.session-card') as HTMLElement;
expect(card.textContent).toContain('0s');
});
it('falls back to "Onbekende handeling" when the activity name is null', async () => {
mockedApiFetch.mockResolvedValue([session({ activity_name: undefined })]);
renderHistory();
expect(await screen.findByText('Onbekende handeling')).toBeInTheDocument();
});
it('omits the type pill when insole_type is null', async () => {
mockedApiFetch.mockResolvedValue([session({ insole_type: null })]);
renderHistory();
const card = (await screen.findByText('Frezen')).closest('.session-card') as HTMLElement;
expect(card.textContent).not.toContain('Kurk');
});
it('renders sessions in the order the API returns them (newest-first)', async () => {
mockedApiFetch.mockResolvedValue([
session({ id: 2, activity_name: 'Frezen' }),
session({ id: 1, activity_name: 'Zagen' }),
]);
const { container } = renderHistory();
await screen.findByText('Frezen');
const names = [...container.querySelectorAll('.session-name')].map((el) => el.textContent);
expect(names).toEqual(['Frezen', 'Zagen']);
});
it('shows neither the empty state nor cards while loading', () => {
mockedApiFetch.mockReturnValue(new Promise<never>(() => {}));
const { container } = renderHistory();
expect(screen.queryByText('Nog geen opgeslagen sessies.')).not.toBeInTheDocument();
expect(container.querySelectorAll('.session-card')).toHaveLength(0);
});
it('lifecycle: renders a completed paused session, then export calls the download path', async () => {
const user = userEvent.setup();
mockedApiFetch.mockResolvedValue([
session({ status: 'completed', duration_seconds: 3661, paused_seconds: 125 }),
]);
renderHistory();
const card = (await screen.findByText('Frezen')).closest('.session-card') as HTMLElement;
expect(card.textContent).toContain('1h 1m');
expect(card.textContent).toContain('Pauze 2m 5s');
await user.click(screen.getByRole('button', { name: 'Exporteer CSV' }));
expect(mockedDownloadExport).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,120 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Login from './Login';
import App from '../App';
import { AuthProvider } from '../auth/AuthContext';
import { clearToken, getToken } from '../lib/auth-storage';
// Keep the real signIn/apiFetch flow (drive it through a stubbed global fetch); only silence the
// authed shell's data fetches so the lifecycle test's screens render without real requests.
vi.mock('../lib/api', async () => {
const actual = await vi.importActual<typeof import('../lib/api')>('../lib/api');
return { ...actual, apiFetch: vi.fn().mockResolvedValue([]) };
});
// A 200 response carrying the bearer token the way better-auth returns it.
function tokenResponse() {
return new Response(null, { status: 200, headers: { 'set-auth-token': 'tok' } });
}
function renderLogin() {
return render(
<AuthProvider>
<Login />
</AuthProvider>,
);
}
describe('Login', () => {
beforeEach(() => {
clearToken();
window.history.replaceState(null, '', '/');
});
afterEach(() => {
vi.restoreAllMocks();
clearToken();
});
it('renders the Dutch login form', () => {
renderLogin();
expect(screen.getByText('E-mailadres')).toBeInTheDocument();
expect(screen.getByText('Wachtwoord')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Inloggen' })).toBeInTheDocument();
});
it('submits the typed credentials to the sign-in endpoint and stores the token', async () => {
const fetchMock = vi.fn().mockResolvedValue(tokenResponse());
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
renderLogin();
await user.type(screen.getByLabelText('E-mailadres'), 'worker@example.com');
await user.type(screen.getByLabelText('Wachtwoord'), 'geheim123');
await user.click(screen.getByRole('button', { name: 'Inloggen' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/api/auth/sign-in/email');
expect(JSON.parse(init.body as string)).toEqual({
email: 'worker@example.com',
password: 'geheim123',
});
expect(getToken()).toBe('tok');
});
it('shows an error message and stores no token when sign-in fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })));
const user = userEvent.setup();
renderLogin();
await user.type(screen.getByLabelText('E-mailadres'), 'x@y.z');
await user.type(screen.getByLabelText('Wachtwoord'), 'wrong');
await user.click(screen.getByRole('button', { name: 'Inloggen' }));
expect(await screen.findByText('Inloggen mislukt')).toBeInTheDocument();
expect(getToken()).toBeNull();
});
it('disables the submit button while the request is pending', async () => {
let release: (() => void) | undefined;
const pending = new Promise<Response>((resolve) => {
release = () => resolve(tokenResponse());
});
vi.stubGlobal('fetch', vi.fn().mockReturnValue(pending));
const user = userEvent.setup();
renderLogin();
const button = screen.getByRole('button', { name: 'Inloggen' });
await user.type(screen.getByLabelText('E-mailadres'), 'a@b.c');
await user.type(screen.getByLabelText('Wachtwoord'), 'pw');
await user.click(button);
await waitFor(() => expect(button).toBeDisabled());
release?.();
await waitFor(() => expect(button).not.toBeDisabled());
});
it('lifecycle: a successful login flips the app to the authenticated shell', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(tokenResponse()));
const user = userEvent.setup();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);
// Unauthenticated: the login form is shown first.
await user.type(screen.getByLabelText('E-mailadres'), 'worker@example.com');
await user.type(screen.getByLabelText('Wachtwoord'), 'geheim123');
await user.click(screen.getByRole('button', { name: 'Inloggen' }));
// Context flips to authenticated → the tabbed shell renders.
const nav = await screen.findByRole('navigation');
expect(within(nav).getByText('Stopwatch')).toBeInTheDocument();
expect(getToken()).toBe('tok');
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -296,4 +296,197 @@ describe('Stopwatch', () => {
);
expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument();
});
it('starts by tapping the display when idle and a handling is chosen', async () => {
const user = userEvent.setup();
renderStopwatch();
await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen');
await user.click(screen.getByRole('button', { name: 'Stopwatch' }));
expect(startMutate).toHaveBeenCalledTimes(1);
});
it('does not start on a display tap when idle with no handling chosen', async () => {
const user = userEvent.setup();
renderStopwatch();
await user.click(screen.getByRole('button', { name: 'Stopwatch' }));
expect(startMutate).not.toHaveBeenCalled();
});
it('decrements the pair count and disables the minus button at the floor of 1', async () => {
const user = userEvent.setup();
renderStopwatch();
const input = screen.getByLabelText('Aantal zolen');
expect(input).toHaveValue('2');
await user.click(screen.getByRole('button', { name: 'Minder zolen' }));
expect(input).toHaveValue('1');
expect(screen.getByRole('button', { name: 'Minder zolen' })).toBeDisabled();
});
it('accepts a free-typed pair count and ignores a non-positive value', async () => {
const user = userEvent.setup();
renderStopwatch();
const input = screen.getByLabelText('Aantal zolen');
await user.clear(input);
await user.type(input, '7');
expect(input).toHaveValue('7');
// A non-positive entry is mirrored in the field but is not adopted as the count.
await user.clear(input);
await user.type(input, '0');
expect(input).toHaveValue('0');
// Starting uses the last valid count (7), proving the '0' was ignored.
await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen');
await user.click(screen.getByRole('button', { name: 'Start Stopwatch' }));
expect(startMutate.mock.calls[0][0]).toMatchObject({ pair_count: 7 });
});
it('clears the chosen handling when the empty option is reselected', async () => {
const user = userEvent.setup();
renderStopwatch();
const select = screen.getByLabelText('Type handeling');
await user.selectOptions(select, 'Frezen');
expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeEnabled();
await user.selectOptions(select, '');
expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeDisabled();
});
it('resets to idle when stop succeeds', async () => {
const user = userEvent.setup();
stopMutate.mockImplementation((_id: number, opts?: { onSuccess?: () => void }) => {
opts?.onSuccess?.();
});
mockedUseActiveSessions.mockReturnValue(
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
);
renderStopwatch();
await user.click(await screen.findByRole('button', { name: 'Stop & Opslaan' }));
expect(await screen.findByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument();
});
it('resets to idle when discard is confirmed and succeeds', async () => {
const user = userEvent.setup();
discardMutate.mockImplementation((_id: number, opts?: { onSuccess?: () => void }) => {
opts?.onSuccess?.();
});
mockedUseActiveSessions.mockReturnValue(
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
);
renderStopwatch();
await user.click(await screen.findByRole('button', { name: 'Annuleren' }));
await user.click(screen.getByRole('button', { name: 'Nogmaals tikken ter bevestiging' }));
expect(discardMutate).toHaveBeenCalledTimes(1);
expect(await screen.findByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument();
});
it('disarms discard after the confirm window expires', () => {
// Fake timers + fireEvent (userEvent's internal delays fight the 1s display tick).
vi.useFakeTimers();
mockedUseActiveSessions.mockReturnValue(
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
);
renderStopwatch();
fireEvent.click(screen.getByRole('button', { name: 'Annuleren' }));
expect(
screen.getByRole('button', { name: 'Nogmaals tikken ter bevestiging' }),
).toBeInTheDocument();
// The 3s arm window lapses without a second tap → discard disarms back to 'Annuleren'.
act(() => {
vi.advanceTimersByTime(3000);
});
expect(screen.getByRole('button', { name: 'Annuleren' })).toBeInTheDocument();
});
it('clears a pending discard timer when the session is reconciled away', async () => {
const user = userEvent.setup();
mockedUseActiveSessions.mockReturnValue(
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
);
const { rerender } = renderStopwatch();
// Arm discard so a pending timer exists when the reconcile reset fires.
await user.click(await screen.findByRole('button', { name: 'Annuleren' }));
await screen.findByRole('button', { name: 'Nogmaals tikken ter bevestiging' });
// Admin stops the session: the poll no longer lists it → resetTimer clears the armed timer.
mockedUseActiveSessions.mockReturnValue(query<ReturnType<typeof useActiveSessions>>([]));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
rerender(
<QueryClientProvider client={queryClient}>
<Stopwatch />
</QueryClientProvider>,
);
expect(
await screen.findByText('Deze sessie is door de beheerder gestopt.'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument();
});
it('clears a pending discard timer on unmount', async () => {
const user = userEvent.setup();
const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
mockedUseActiveSessions.mockReturnValue(
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
);
const { unmount } = renderStopwatch();
await user.click(await screen.findByRole('button', { name: 'Annuleren' }));
clearSpy.mockClear();
unmount();
expect(clearSpy).toHaveBeenCalled();
});
it('lifecycle: running locally, then a poll without the session resets it and shows the admin-stopped notice', async () => {
const user = userEvent.setup();
const local = activeSession({ id: 42 });
startMutate.mockImplementation(
(_input: unknown, opts?: { onSuccess?: (s: WorkSession) => void }) => {
opts?.onSuccess?.(local);
},
);
// Idle to begin with (no server-active session to adopt).
mockedUseActiveSessions.mockReturnValue(query<ReturnType<typeof useActiveSessions>>([]));
const { rerender } = renderStopwatch();
// Start locally → the screen enters the running state from the start mutation's onSuccess.
await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen');
await user.click(screen.getByRole('button', { name: 'Start Stopwatch' }));
await screen.findByRole('button', { name: 'Stop & Opslaan' });
// The active-sessions poll now returns a list WITHOUT our session (only another worker's).
mockedUseActiveSessions.mockReturnValue(
query<ReturnType<typeof useActiveSessions>>([activeSession({ id: 77, user_id: 'other' })]),
);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
rerender(
<QueryClientProvider client={queryClient}>
<Stopwatch />
</QueryClientProvider>,
);
// The timer resets to idle and the admin-stopped notice shows.
expect(
await screen.findByText('Deze sessie is door de beheerder gestopt.'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument();
});
});

View File

@@ -75,6 +75,7 @@ export default function Stopwatch() {
// Restore pause state from the server (source of truth).
const pausedAtMs = session.paused_at ? new Date(session.paused_at).getTime() : null;
setIsPaused(pausedAtMs !== null);
/* c8 ignore next -- paused_seconds is z.number().int() (never null); ?? 0 is a defensive coalesce */
setPausedMs((session.paused_seconds ?? 0) * 1000);
setPauseStartedMs(pausedAtMs);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -102,6 +103,7 @@ export default function Stopwatch() {
const elapsed = startMs !== null ? elapsedSeconds(startMs, effectiveNow, pausedMs) : 0;
function handleSelectZool(type: InsoleType) {
/* c8 ignore next -- unreachable: the zool buttons are disabled while running */
if (isRunning) return;
setInsoleType(type);
setActiveActivityId(null); // changing the zool clears the chosen handling
@@ -120,6 +122,7 @@ export default function Stopwatch() {
}
function handleStart() {
/* c8 ignore next -- unreachable: both callers gate on canStart; guard also narrows the type */
if (!canStart || activeActivityId === null) return;
setStoppedByAdmin(false); // clear any prior admin-stopped notice
startSession.mutate(
@@ -177,6 +180,7 @@ export default function Stopwatch() {
}
function handleStop() {
/* c8 ignore next -- unreachable: the Stop button only renders while running (sessionId set) */
if (sessionId === null) return;
const id = sessionId;
stopSession.mutate(id, { onSuccess: () => resetTimer(), onError: resetIfConflict });
@@ -184,6 +188,7 @@ export default function Stopwatch() {
}
function handleDiscard() {
/* c8 ignore next -- unreachable: the Annuleren button only renders while running (sessionId set) */
if (sessionId === null) return;
if (!discardPending) {
setDiscardPending(true);

View File

@@ -3,5 +3,16 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'] },
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.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'],
},
},
});

View File

@@ -0,0 +1,616 @@
# Domain test checklist — guaranteed behaviors
Living definition-of-done. Each domain lists the behaviors its tests guarantee.
When you change a domain, its box must still hold (or you update this list on purpose).
Baseline established 2026-07-22 (see plan `2026-07-22-per-domain-test-safety-net`).
Tracked as Plane epic **SL-70**.
## Coverage: baseline (Phase A) → final (Phase C), 2026-07-22
| Workspace | Stmts (before → after) | Branch (before → after) | Funcs (before → after) | Tests (before → after) |
| --------- | ---------------------- | ----------------------- | ---------------------- | ---------------------- |
| api | 96.94 → **100** | 74.29 → **100** | 100 → **100** | 103 → **175** |
| worker | 89.62 → **100** | 87.42 → **100** | 70.58 → **100** | 30 → **79** |
| admin | 92.43 → **100** | 84.91 → **100** | 78.75 → **100** | 50 → **129** |
All three workspaces reach **100%** statements/branches/functions/lines. Genuinely
unreachable/defensive lines are annotated `/* c8 ignore next -- <reason> */` (never left silent).
No bugs were found during the sweep — every added test locks in already-correct behavior.
<!-- Phase B appends one "## <domain>" section per domain below. -->
## Infra (health/db/schema/seed/env/migrate)
Src covered: `src/routes/health.ts`, `src/db/client.ts`, `src/db/ensure-dir.ts`,
`src/db/migrate.ts`, `src/db/seed.ts`, `src/env.ts` — all at 100% stmts/branch/funcs/lines.
(`src/db/schema.ts` is generated and excluded from coverage by config.)
Guaranteed behaviors:
- `GET /health` returns `{ status: 'ok' }`.
- `env.ts` applies every dev default (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`,
`PORT`, `WEB_ORIGINS`) when the vars are unset, and reads each override when set (`PORT`
coerced to a number).
- `env.WEB_ORIGINS` parses `CORS_ORIGINS` into a list, trimming whitespace and dropping empty
entries; falls back to the default localhost origins when `CORS_ORIGINS` is empty/unset.
- `runMigrations()` is idempotent — re-running it against an already-migrated DB does not throw.
- `ensureDbDir()` is a no-op for non-`file:` URLs and for a bare filename in the current dir.
- `seed()` seeds the reference activities idempotently and seeds the dev worker + dev admin with
the correct roles, idempotently.
- `seed()` never creates the dev users when `NODE_ENV=production` (reference activities still
seeded).
- The `migrate.ts` / `seed.ts` CLI direct-run blocks are `c8 ignore`d — unreachable under the
test runner (exercised only via `tsx src/db/{migrate,seed}.ts`).
## Auth & /me
Src covered: `src/auth.ts`, `src/routes/me.ts`, `src/lib/require-user.ts`, and the CORS
middleware in `src/app.ts` — all at 100% stmts/branch/funcs/lines.
Guaranteed behaviors:
- Sign-in with valid credentials returns a bearer token (`set-auth-token` header); sign-in with
a wrong password fails (status ≥ 400).
- Public sign-up is rejected (`disableSignUp` — admin creates users).
- `GET /api/me` returns 401 without a token, and `{ user: { id, email, name, role } }` with a
valid bearer; role reflects the user (`worker` / `admin`).
- `GET /api/me` falls back to role `'worker'` when the session user has no role set.
- `getSessionUser` maps `id`, `role`, and `banned` from the session; defaults `role` to
`'worker'` and `banned` to `false` when unset; returns `null` when there is no session.
- `isAdmin` is true only for the `admin` role (false for `worker` and for `null`).
- `adminGuard` returns 401 without a session, 403 for a non-admin, and 403 for a banned admin
that still holds a valid token; it passes through for an active admin. (Exercised through the
`/api/admin/*` surface in `admin-users.test.ts`.)
- CORS: the allowed SPA origins (worker 5173, admin 5174) are echoed on the
`access-control-allow-origin` header, OPTIONS preflight is answered with the allowed methods,
and `set-auth-token` is exposed so the SPA can read the bearer on sign-in.
## Activities
Src covered: `src/routes/activities.ts` — 100% stmts/branch/funcs/lines. The one unreachable
branch (`row.sortOrder ?? 0` in `toActivity`; `sort_order` is `NOT NULL DEFAULT 0`) is
`c8 ignore`d with a reason, not left silent.
Guaranteed behaviors:
- `GET /api/activities` requires a session (401 without a token); any authenticated user —
worker or admin — may read.
- The list is ordered by `sort_order` then `name`, and `?insole_type=<type>` filters to
activities whose `insole_types` include that type.
- `POST`/`PUT`/`DELETE`/`PUT /reorder` are admin-only: 401 without a token, 403 for a worker.
- Create validates a non-empty (trimmed) `name` (400 otherwise) and defaults `insole_types` to
all three (`Kurk`, `Berk`, `3D`) when omitted.
- Create appends the new activity with a `sort_order` one higher than the current maximum.
- `insole_types` round-trips through create/list as the exact stored subset.
- Update (`PUT /:id`) 404s for a non-numeric or unknown id, 400s on an invalid body, and returns
the updated activity otherwise.
- Delete (`DELETE /:id`) 404s for a non-numeric id, removes the activity **and** its work-session
rows (no FK cascade), and returns `{ success: true }`.
- Reorder (`PUT /reorder`) sets `sort_order` from the `ids` array position; it 400s on an empty
array (zod `min(1)`) and when the ids are not exactly the full known set (wrong count or an
unknown id).
- Lifecycle: an admin creates three activities → reorders them → a worker's GET reflects the new
order → the admin deletes one → it is gone from the list.
## Work-sessions
Src covered: `src/routes/sessions.ts` and `src/lib/work-session.ts` — both at
100% stmts/branch/funcs/lines. The two `row.pausedSeconds ?? 0` guards in `sessions.ts`
(resume/stop) are `c8 ignore`d with a reason — `paused_seconds` is `NOT NULL DEFAULT 0`, so the
null branch is unreachable via any route.
Guaranteed behaviors:
- `start`, `pause`, `resume`, `stop`, `discard` all require a session (401 without a token), as do
the reads `GET /api/sessions` and `GET /api/sessions/active`.
- `POST /api/sessions/start`: 200 creates an `active`, `source: 'app'` session (null `end_time` /
`duration_seconds`); 400 on an invalid body **and** on a malformed-JSON body; 404 when the
`activity_id` does not exist.
- `POST /:id/pause`: 200 sets `paused_at` on a running session; 404 for a non-numeric or unknown
id; 409 on a double pause (already paused) and 409 when the session is no longer active.
- `POST /:id/resume`: 200 clears `paused_at` and accumulates `paused_seconds`; 404 for a
non-numeric or unknown id; 409 when the session is running (not paused) and 409 when it is no
longer active.
- `POST /:id/stop`: 200 completes the session, folds an open pause span into `paused_seconds`, and
computes `duration_seconds = wall-clock − paused_seconds` (never negative); a never-paused stop
keeps `paused_seconds = 0` and `duration = wall-clock`; 404 for a non-numeric/unknown id; 409
when already closed.
- `POST /:id/discard`: 200 marks the session `discarded` with a null `duration_seconds`; 404 for a
non-numeric/unknown id; 409 when already closed.
- Ownership scoping: user B can neither stop nor discard user A's session — both return 404 and
leave A's session untouched (still `active`).
- Reads: `GET /api/sessions` returns the caller's sessions only, newest-first, joined to the
activity `name`; `GET /api/sessions/active` returns only that user's `active` sessions.
- `toWorkSession` maps every field to the API contract (incl. `paused_at`, `paused_seconds`,
`notes`, `created_at`), threads `activity_name` / `user_name` / `user_email` opts through
(undefined when omitted), maps a null `insoleType` / `durationSeconds` / `notes` to null, and
coalesces a nullish `paused_seconds` to 0.
- Lifecycle: start → pause → (backdate) resume accumulates paused time → (backdate) stop excludes
paused time from worked duration → the completed session appears in history with its activity
name → `GET /api/export` returns a CSV with exactly the header + one data row.
## Worker export/CSV
Src covered: `src/lib/csv.ts` — 100% stmts/branch/funcs/lines; the `/api/export` route in
`src/routes/sessions.ts` — 100%. No `c8 ignore`s needed (every fallback branch is exercised).
Guaranteed behaviors:
- `buildSessionsCsv(rows)` (worker self-export, `includeWorker` defaults to false) emits the exact
legacy header `ID,Task,Insole Type,No. of Insoles,Date,Total Duration,Paused Duration,Start
Time,End Time` (each cell quoted) with no Worker column.
- `buildSessionsCsv(rows, { includeWorker: true })` prepends a `Worker` column carrying `userName`.
- Every field is quoted; `quote` escapes embedded double quotes (doubled) and keeps commas and
newlines inside the quoted cell — a comma/quote/newline in an activity name never breaks the
column or row structure.
- Legacy fallbacks: a null `activityName`/`userName` renders `''`, null `insoleType` renders
`Kurk`, null `pairCount` renders `2`, null `durationSeconds`/`pausedSeconds` render `00:00:00`,
and a null `endTime` renders an empty End Time cell.
- `formatDuration` renders zero-padded `HH:MM:SS`, handles zero (`00:00:00`), and lets hours exceed
99 (`360000 → 100:00:00`).
- `GET /api/export`: 401 without a token; includes only the caller's `completed` sessions
(active/discarded and other users' rows excluded); rows ordered by start time ascending; sets the
`text/csv` content-type and the `attachment; filename="insole-production-report.csv"` disposition
header.
- Lifecycle: covered by the worker-session anchor (`test/integration/worker-session-lifecycle.test.ts`),
whose final step asserts `GET /api/export` returns the header + exactly one completed-session row.
## Admin sessions
Src covered: the session parts of `src/routes/admin.ts` (cross-user list, active list, manual
create/edit, admin stop/discard) — all session-part statements, lines, and branches covered, so
`admin.ts` is at 100% stmts/lines. The one `row.pausedSeconds ?? 0` guard in the admin stop route
is `c8 ignore`d with a reason — `paused_seconds` is `NOT NULL DEFAULT 0`, so the null branch is
DB-impossible. (The report/export parts of `admin.ts` — `parseReportQuery`, the aggregation `??`
fallbacks, and the export invalid-query branch — are owned and covered by the Reports domain.)
Guaranteed behaviors:
- Every `/api/admin/*` session route is gated: 401 without a token, 403 for a worker.
- `GET /api/admin/sessions` returns **all** users' sessions newest-first, each joined to
`activity_name`, `user_name`, and `user_email`; `GET /api/admin/sessions/active` is the same but
filtered to `active` sessions only.
- `POST /api/admin/sessions` (manual create): creates a `completed`, `source: 'manual'` session
with `duration_seconds = span − paused_seconds` and null `paused_at`; 400 on a schema-invalid
body, on `end_time < start_time`, and when `paused_seconds` exceeds the worked span; 404 for an
unknown `user_id` and for an unknown `activity_id`.
- `PUT /api/admin/sessions/:id` (edit): recomputes `duration_seconds` when `end_time` is present and
sets it null when `end_time` is null (re-activating); 404 for a non-numeric or unknown id; 400 on
a schema-invalid body, on an unparseable `start_time`, on `end_time < start_time`, and when
`paused_seconds` exceeds the span; 404 for an unknown `activity_id`.
- `POST /api/admin/sessions/:id/stop`: completes another user's active session, folds an open pause
span into `paused_seconds`, clears `paused_at`, and computes `duration_seconds` excluding paused
time; 404 for a non-numeric or unknown id; 409 when the session is already closed.
- `POST /api/admin/sessions/:id/discard`: marks another user's active session `discarded` with an
`end_time`; 404 for a non-numeric or unknown id; 409 when the session is already closed.
- Lifecycle (`test/integration/admin-worker-session.test.ts`): a worker starts a session → an admin
stops it via the admin endpoint → the completed session appears in `GET /api/admin/report`
(totals, `by_worker`, `by_activity`, `by_type` all matching the stored row) and in
`GET /api/admin/export` (header + exactly one row, with the leading Worker column).
## Reports + admin export
Src covered: the report/export parts of `src/routes/admin.ts` (`parseReportQuery`,
`buildSessionFilters`, `GET /api/admin/report`, `GET /api/admin/export`) — `admin.ts` is at 100%
stmts/branch/funcs/lines. Three defensive aggregation `??` guards are `c8 ignore`d with reasons
rather than left silent: `pausedSeconds`/`pairCount` are `NOT NULL` (DB-impossible nulls), and the
`user_name`/`activity_name` fallbacks are unreachable because the `user_id` FK cascades on delete
and the `activity_id` FK is enforced (every session always joins to a real user and activity).
Guaranteed behaviors:
- Both `GET /api/admin/report` and `GET /api/admin/export` are gated: 401 without a token, 403 for a
worker.
- Query parsing (`parseReportQuery`, shared by report + export): 400 when `from`/`to` is missing,
unparseable, or inverted (`to < from`); 400 on an `insole_type` outside `Kurk`/`Berk`/`3D`; 400 on
a non-numeric `activity_id`; the export route rejects the same bad queries with 400.
- Report scope: only `completed` sessions inside `[from, to]` (by `start_time`) are counted —
active/discarded rows are excluded.
- Filters narrow the result set: `user_id`, `insole_type`, and `activity_id` each restrict the
counted sessions (individually and combined).
- Totals sum `worked_seconds`, `paused_seconds`, `pairs`, and `sessions` across every matched row,
and equal the sum of each breakdown.
- Breakdowns `by_worker`, `by_activity`, and `by_type` group correctly and are each sorted by
`worked_seconds` descending.
- A completed session with a null `insole_type` is bucketed under `"Onbekend"` in `by_type` (worker
and activity buckets stay real); a completed session with a null `duration_seconds` contributes
`0` worked seconds.
- An empty range yields zero totals and empty `by_worker`/`by_activity`/`by_type` arrays.
- Admin export: emits an all-users CSV with the leading `Worker` column; the `text/csv` content-type
and a `Content-Disposition` filename of `solelog-report_<from>_<to>.csv` (dates sliced to
`YYYY-MM-DD`); rows ordered by start time ascending; honors the same filters as the report.
- Lifecycle (`test/integration/report-aggregation.test.ts`): seed known completed sessions for two
workers × two activities × two insole types → assert exact totals and every `by_worker`,
`by_activity`, `by_type` row (values and descending order) → apply a `user_id` filter → assert the
totals and breakdowns narrow to that worker only.
## Admin users
Src covered: `src/routes/admin-users.ts` — 100% stmts/branch/funcs/lines. The two last-admin
guards (in the role and deactivate routes) are `c8 ignore`d with reasons: they are unreachable
over HTTP because the self-demote / self-deactivate guards above already handle `caller === target`,
so any caller reaching them is a distinct active admin and `activeAdminsExcluding(target) >= 1`; the
guard logic itself is unit-tested through `activeAdminsExcluding`.
Guaranteed behaviors:
- Every `/api/admin/users*` route is gated: 401 without a token, 403 for a worker, and 403 for a
banned admin that still holds a valid token (`adminGuard` defense-in-depth).
- `GET /api/admin/users` returns every user ordered by name, each mapped by `toListItem` to
`{ id, email, name, role, status, created_at }`; a null DB `role` falls back to `worker` and a
`banned` row surfaces as `status: 'inactive'`.
- `POST /api/admin/users` creates a user who can then sign in; 409 on a duplicate email; 400 on a
schema-invalid body (incl. a password shorter than 8 chars); a mixed-case email is stored
lowercased and the created row is returned (no 500 on the re-select).
- `POST /:id/role` promotes/demotes and returns the updated row; 400 on an invalid role body; 404 on
an unknown id; refuses self-demotion (400); allows demoting a peer admin while the caller stays
admin (200).
- `activeAdminsExcluding` (unit) counts only active (non-banned) admins and honours the excluded id.
- `POST /:id/deactivate` bans the user, clears any live session rows, and returns
`status: 'inactive'`; 404 on an unknown id; refuses self-deactivation (400); allows deactivating a
peer admin while always blocking self.
- `POST /:id/reactivate` clears the ban and returns `status: 'active'`; 404 on an unknown id.
- `POST /:id/password` sets a new password (new works, old fails); 400 on a too-short password; 404
on an unknown id; surfaces a 400 (not a 500) when better-auth's `setUserPassword` throws.
- Lifecycle (`test/integration/user-lifecycle.test.ts`): admin creates a worker → worker signs in
and the bearer authorizes `/api/me` → admin deactivates → the session rows are deleted so the
pre-issued token no longer authorizes `/api/me` (401) **and** a fresh sign-in is blocked → admin
reactivates → the ban is cleared and sign-in works again.
## Auth (context/storage/login) — worker client
Src covered (worker `apps/worker`): `src/auth/AuthContext.tsx`, `src/lib/auth-storage.ts`, and
`src/screens/Login.tsx` — all at 100% stmts/branch/funcs/lines. No `c8 ignore`s needed.
Guaranteed behaviors:
- `auth-storage`: `setToken` persists the bearer, `getToken` reads it back, `clearToken` removes it
(`getToken` returns `null` when nothing is stored) — all via `localStorage`.
- `AuthContext` starts **unauthenticated** when no token is stored and **restores** the
authenticated state on mount when a token is already present (`getToken() !== null`).
- `AuthContext.signIn` delegates to the api `signIn(email, password)` and, on success, flips the
context to authenticated (`isAuthed` → true).
- `AuthContext.signOut` resets the router path to `/` (so the next authed mount lands on Stopwatch,
not Account), clears the stored token, and flips the context to unauthenticated.
- `useAuth` throws `"useAuth must be used within an AuthProvider"` when called outside the provider.
- `Login` renders the Dutch form (E-mailadres / Wachtwoord / Inloggen) and submits the typed
credentials to `POST /api/auth/sign-in/email`, storing the returned bearer token.
- `Login` shows the `"Inloggen mislukt"` error (and stores no token) when sign-in fails, and
disables the submit button while the request is pending, re-enabling it once it settles.
- Lifecycle (`src/screens/Login.test.tsx`): from the unauthenticated app, a successful login stores
the token and flips the context so the tabbed authenticated shell (Stopwatch + TabBar) renders.
> Note: the design checklist mentions "clears on 401" for the worker auth context — this is **not
> implemented** in the current code (there is no global 401 interceptor; `apiFetch` throws
> `ApiError` and callers surface it via React Query). Left as-is (missing feature, not a defect);
> not tested and not filed as a bug.
## History — worker client
Src covered (worker `apps/worker`): `src/screens/History.tsx` and `src/lib/export.ts` — both at
100% stmts/branch/funcs/lines. No `c8 ignore`s needed.
Guaranteed behaviors:
- History renders the Dutch header (`Geschiedenis`) and the `Exporteer CSV` button.
- Sessions render in the order the API returns them (`GET /api/sessions` is newest-first), one
`.session-card` per session, showing the activity name and its formatted duration.
- `formatDuration` renders the legacy format: `Hh Mm` when hours are present (`3661 → 1h 1m`),
`Mm Ss` when only minutes (`125 → 2m 5s`), and just seconds under a minute (`45 → 45s`); a null
`duration_seconds` renders `0s`.
- A card falls back to `"Onbekende handeling"` when `activity_name` is absent, omits the insole-type
pill when `insole_type` is null, and uses the singular noun (`inlegzool`) only for a pair count of
1 (`inlegzolen` otherwise).
- A `Pauze <duration>` line shows only when `paused_seconds > 0` (absent when it is 0).
- The empty state (`Nog geen opgeslagen sessies.`) shows only once loading has finished and there are
no sessions; while the query is still loading, neither the empty state nor any cards render.
- Clicking `Exporteer CSV` calls `downloadExport`; when it rejects, the card surfaces the
`Kan de export niet openen` error message instead of throwing.
- `downloadExport` (`src/lib/export.ts`): fetches `GET /api/export` with the bearer token when one is
stored (no `Authorization` header when there is none), then triggers a client-side download via an
object URL (`createObjectURL` → anchor `click` → `revokeObjectURL`); it throws
`"Kan de export niet openen"` when the response is not ok.
- Lifecycle (`src/screens/History.test.tsx`): the list renders a completed session showing both its
worked duration and its `Pauze` line, then the export action invokes the download path.
## Account — worker client
Src covered (worker `apps/worker`): `src/screens/Account.tsx` — 100% stmts/branch/funcs/lines.
No `c8 ignore`s needed.
Guaranteed behaviors:
- Account renders the Dutch header (`Account`) and, once `GET /api/me` resolves, shows the signed-in
user's name and email from `useMe()`.
- The fallback message `Kon accountgegevens niet laden.` shows when the query resolves without a
`user` (and the name/email are then absent).
- Clicking `Uitloggen` calls `signOut`, which clears the stored bearer token.
- Lifecycle (`src/screens/Account.test.tsx`): from the authenticated app on the Account screen,
clicking `Uitloggen` flips the auth context to unauthenticated so the login form
(`E-mailadres`) renders and the `Uitloggen` button is gone (token cleared).
## lib/api + app/nav — worker client
Src covered (worker `apps/worker`): `src/lib/api.ts`, `src/App.tsx`, `src/components/TabBar.tsx`,
`src/api/sessions.ts`, `src/api/activities.ts`, `src/api/me.ts` — all 100% stmts/branch/funcs/lines.
No `c8 ignore`s needed.
Guaranteed behaviors:
- `apiFetch` attaches `Authorization: Bearer <token>` when a token is stored and omits it otherwise.
- `apiFetch` sets `Content-Type: application/json` when a request has a body and no explicit
Content-Type, and does not override a Content-Type the caller supplied.
- `apiFetch` parses a JSON response body, and returns `undefined` when the body is empty.
- `apiFetch` throws `ApiError` (carrying the HTTP status) on any non-2xx response.
- `signIn` POSTs to `/api/auth/sign-in/email`, stores the token from the `set-auth-token` header,
and throws `ApiError` when the response is not ok or the header is missing.
- `App` gates on auth state: no token renders `Login` (no tab bar); a token renders the authed shell
with the Stopwatch screen mounted at `/` beside the `TabBar`.
- `TabBar` renders the three tabs (`Stopwatch` → `/`, `Geschiedenis` → `/history`,
`Account` → `/account`) as links, marks the current route's tab `tab-active`, and uses `end`
matching so the Stopwatch tab is active only on the root path.
- Each `api/*` hook hits the right path/method: `useSessions` → GET `/api/sessions`;
`useActiveSessions` → GET `/api/sessions/active`; `useStartSession` → POST `/api/sessions/start`
with the serialized input; `useStop/Discard/Pause/ResumeSession` → POST `/api/sessions/:id/<action>`;
`useActivities` → GET `/api/activities`; `useMe` → GET `/api/me`. Every mutation invalidates the
`['sessions']` query cache on success.
- Lifecycle (`src/App.test.tsx`): unauthenticated renders `Login` with no navigation; authenticated
renders the Stopwatch screen under the tab bar and no login form.
## Auth (admin-only gate) — admin client
Src covered (admin `apps/admin`): `src/auth/AuthContext.tsx` and `src/screens/Login.tsx` — both at
100% stmts/branch/funcs/lines. No `c8 ignore`s needed.
Guaranteed behaviors:
- `AuthContext` starts **unauthenticated** when no token is stored and **restores** the authenticated
state on mount when a token is already present (`getToken() !== null`).
- `AuthContext.signIn` delegates to the api `signIn(email, password)`, then fetches `/api/me`:
**admin-only gate** — an `admin` role flips the context to authenticated; a non-admin role clears the
stored token and throws `NotAdminError` (`"not-admin"`), leaving the context unauthenticated.
- `AuthContext.signOut` resets the router path to `/` (so the next authed mount lands on Live, not
whatever tab the admin logged out from), clears the stored token, and flips to unauthenticated.
- `useAuth` throws `"useAuth must be used within an AuthProvider"` when called outside the provider.
- `Login` renders the Dutch form (E-mailadres / Wachtwoord / Inloggen) and submits the typed
credentials to `signIn`.
- `Login` distinguishes the two failure modes: a `NotAdminError` surfaces
`"Geen toegang — alleen beheerders."` while any other failure surfaces `"Inloggen mislukt"`; a stale
error is cleared on the next submit.
- `Login` disables the submit button while the sign-in is pending, re-enabling it once it settles.
- Lifecycle (`src/screens/Login.test.tsx`): from the unauthenticated app, worker credentials are
rejected with the `"Geen toegang — alleen beheerders."` message and the shell never renders; admin
credentials then flip the gate so the authed shell (Sidebar nav) renders.
## Live view — admin client
Src covered (admin `apps/admin`): `src/screens/Live.tsx` and `src/lib/elapsed.ts` — both at
100% stmts/branch/funcs/lines. No `c8 ignore`s needed.
Guaranteed behaviors:
- Live renders one `.live-card` per active session under a `Actief nu (<count>)` header, each card
showing the worker name, the activity, the pair count, and a running worked-time timer.
- The per-card worked time is server-authoritative: `worked = (base − start_time) − paused_seconds`,
where `base` is `now` while running (a 1s client tick counts it up between the 5s server polls) and
the frozen `paused_at` moment while paused, floored at 0. `formatTime` (in `elapsed.ts`) renders it
as zero-padded `HH:MM:SS` and never returns a negative time.
- A running (not-paused) card's timer keeps counting up on the 1s tick; a paused card shows the
`Gepauzeerd` badge and its worked timer stays frozen at the pause moment across ticks.
- The `Pauze <duration>` total line shows only when `paused_seconds > 0` (absent when it is 0).
- Placeholder fallbacks: a missing `user_name` renders `Onbekend`, a missing `activity_name` renders
`Onbekende handeling`, and a null `insole_type` omits the type pill.
- Each card exposes `Stop` and `Annuleer` buttons that call `POST /api/admin/sessions/:id/stop` and
`.../discard` for that session; both buttons are disabled while either mutation is in flight.
- Loading state renders `Laden…`; the error state renders `Kon gegevens niet laden.` (and no header);
the empty state renders `Niemand is nu aan het werk.`; a non-array payload is treated as zero active
sessions (defensive `Array.isArray` fallback).
- Lifecycle (`src/screens/Live.test.tsx`): active-sessions data renders a running card and a paused
card side by side, with exactly the paused session showing the `Gepauzeerd` badge and its
`Pauze 00:01:30` total.
## Sessions + SessionForm — admin client
Src covered (admin `apps/admin`): `src/screens/Sessions.tsx` and `src/components/SessionForm.tsx` —
both at 100% stmts/branch/funcs/lines. One `c8 ignore` with a reason: `localToIso`'s
`Number.isNaN` guard is unreachable because a `datetime-local` input only ever yields a valid
datetime string or `''`.
Guaranteed behaviors:
- Sessions renders one `.sessions-row` per session (worker, activity + insole-type chip, worked
time, start date, status badge). Row worked-time is server-authoritative: completed/discarded rows
use `duration_seconds`; an active row counts `(base − start) − paused_seconds` where `base` is the
frozen `paused_at` moment while paused (else `now`), floored at 0.
- The `Status` filter narrows the list to `all`/`active`/`completed`/`discarded`; the empty message is
`Nog geen sessies.` when there are truly none and `Geen sessies in dit filter.` when the filter
hides every row.
- The query's error state renders `Kon sessies niet laden.` (no list); the loading state renders
`Laden…`; a non-array payload is treated as zero sessions.
- Row placeholders: a missing `user_name` renders `Onbekend` (and the edit aria-label uses
`onbekend`), a missing `activity_name` renders `Onbekende handeling`, a null `insole_type` omits the
type chip, and an unparseable `start_time` renders an empty date cell; the `Pauze <time>` line shows
only when `paused_seconds > 0`.
- `Stop` and `Annuleer` appear only on `active` rows and call `POST /api/admin/sessions/:id/stop` and
`.../discard`; the `✎` edit button appears on every row and opens the shared form in edit mode.
- `+ Nieuwe registratie` opens the shared form in create mode; cancelling (`Annuleren`) closes it and
returns to the list.
- SessionForm create mode shows the medewerker picker (hidden in edit mode) and POSTs a
`CreateManualSessionInput`; edit mode hides the picker, shows a `Status` select, prefills from the
session, and PUTs an `AdminUpdateSessionInput`.
- Create validation: an empty handeling/starttijd shows `Vul handeling en starttijd in.` and blocks the
POST; a filled handeling+starttijd but missing medewerker/zooltype/eindtijd shows
`Vul medewerker, zooltype, start- en eindtijd in.`.
- Serialization: the notitie is trimmed (empty → `null`); in edit mode an empty zooltype serializes to
`insole_type: null` and status `active` serializes `end_time: null`; `Aantal paren` floors at 1 and
`Pauze (minuten)` floors at 0 on invalid/empty input.
- The gewerkt preview renders `end − start − paused` (`—` when the span is indeterminate); a
prefilled unparseable date falls back to an empty field; a 400 from the API surfaces
`Controleer de ingevoerde gegevens …` while any other failure surfaces `Opslaan mislukt …`.
- Edit mode without a session is a defensive no-op on submit (no PUT is issued).
- Lifecycle (`src/screens/Sessions.test.tsx`): open the create form → fill a valid manual session →
submit → it appears in the list; then edit that row (change the handeling) → the change persists in
the refreshed list.
## Reports + date-range — admin client
Src covered (admin `apps/admin`): `src/screens/Reports.tsx`, `src/api/reports.ts`, and
`src/lib/date-range.ts` — all three at 100% stmts/branch/funcs/lines. No `c8 ignore` needed.
Guaranteed behaviors:
- Date-range helpers derive ISO instants in the admin's local timezone: `dayStartISO` → local
00:00:00.000 of the picked day, `dayEndISO` → local 23:59:59.999; `toDateStr` zero-pads month/day.
- Presets map to the correct `YYYY-MM-DD` windows: `thisWeek` = Monday..today, `thisMonth` =
1st..today, `allTime` = fixed floor `2000-01-01`..today.
- `filtersToQuery` always serializes `from`/`to` and only emits `user_id`/`insole_type`/`activity_id`
when set (empty optionals are omitted).
- `useReport` calls `GET /api/admin/report?<query>`; clicking a preset (`Deze week`/`Deze maand`/
`Alles`), changing the Van/Tot date inputs, or picking a Medewerker/Type/Handeling filter re-issues
the report query with the new params (verified against the emitted querystring).
- The screen renders headline totals (`worked`/`pairs`/`sessions`/`paused`) and the three breakdown
tables (`Per medewerker`/`Per handeling`/`Per type`) from the response rows.
- Loading state renders `Laden…`; the query error state renders `Kon rapport niet laden.`; a report
with no data falls back to zero totals and shows `Geen gegevens.` in every table (the `?? 0` / `?? []`
defensive fallbacks).
- `Exporteer CSV` calls `downloadExport` with the current filters; a rejected download surfaces
`Export mislukt. Probeer opnieuw.`.
- `downloadExport` fetches `/api/admin/export?<query>` with a `Bearer` header when a token exists (and
no `Authorization` header when it doesn't), throws on a non-ok response, names the file from the
`content-disposition` header, and falls back to `solelog-report.csv` when that header is absent.
- Lifecycle (`src/screens/Reports.test.tsx`): pick `Deze maand` + a Medewerker filter → the report
query reflects both (`from` = this-month start, `user_id` set) and the returned rows still render.
## Users — admin client
Src covered (admin `apps/admin`): `src/screens/Users.tsx`, `src/components/UserForm.tsx`, and
`src/api/users.ts` — `UserForm.tsx` and `users.ts` at 100% stmts/branch/funcs/lines; `Users.tsx` at
100% stmts/branch/funcs/lines. Two `c8 ignore`s with reasons in `Users.tsx`: the `usersQuery.data ?? []`
fallback (defensive — the isLoading/isError branches return above it, so `data` is always set) and the
`if (pw.length < 8) return;` guard in `onReset` (defensive — the Opslaan button is disabled while the
password is shorter than 8, so it never fires).
Guaranteed behaviors:
- The screen lists one row per user with a role pill (`Beheerder`/`Werker`) and a status pill
(`Actief`/`Inactief`), plus name, email, and a locale-formatted created date.
- The signed-in admin's own row (matched by `useMe().user.id`) shows a `jij` badge and hides the
role-toggle and deactivate/reactivate actions (a user can never demote or deactivate themselves via
the UI); other rows show those actions.
- The loading state renders `Laden…` and the query error state renders `Kon gebruikers niet laden.`.
- `+ Nieuwe gebruiker` toggles the create form; submitting it POSTs a `CreateUserInput`
(`{ name, email, password, role }`) to `/api/admin/users`; `Annuleer` closes the form without a POST.
- Create errors surface inline as `E-mailadres bestaat al of ongeldig.` (the `useCreateUser` onError
path), which renders in the `UserForm` error slot.
- `UserForm`: name/email are trimmed into the payload and the selected `Rol` is carried through; a
password shorter than 8 shows `Minstens 8 tekens.` and blocks submission (the `password.length < 8`
guard); the submit button is disabled while `pending`.
- The role button promotes a worker to admin (`{ role: 'admin' }`) or demotes an admin to worker
(`{ role: 'worker' }`) via `POST /api/admin/users/:id/role`.
- Inline password reset: `Reset wachtwoord` reveals a password field with `Opslaan`/`Annuleer`;
`Opslaan` stays disabled until the new password is at least 8 chars, then POSTs `{ password }` to
`/api/admin/users/:id/password` and closes the field on success; `Annuleer` closes it without a POST.
- `Deactiveer` POSTs to `/api/admin/users/:id/deactivate` (shown on active peers) and `Heractiveer`
POSTs to `/api/admin/users/:id/reactivate` (shown on inactive peers).
- Every mutation (`create`/`setRole`/`resetPassword`/`deactivate`/`reactivate`) invalidates the
`['admin','users']` query so the list refetches and reflects the change.
- Lifecycle (`src/screens/Users.test.tsx`): create a worker via the form → it appears in the refreshed
list as `Werker` → promote it via `Maak admin` → the pill flips to `Beheerder` after the refetch →
reset its password inline (`Opslaan`) → the POST fires and the inline field closes.
## Activities + app/nav — admin client
Src covered (admin `apps/admin`): `src/screens/Activities.tsx`, `src/api/activities.ts`,
`src/App.tsx`, and `src/components/Sidebar.tsx` — all at 100% stmts/branch/funcs/lines. Three
`c8 ignore`s with reasons in `Activities.tsx`: the early-return guards in `handleAdd`, `handleSave`,
and `handleMove` are unreachable via the UI because the button that triggers each is disabled in
exactly that state (`Stap toevoegen` when `!addEnabled`, `Opslaan` when the name/types are empty,
the ↑/↓ move buttons at the list bounds).
Guaranteed behaviors:
- Activities renders the Dutch header (`Handelingen` / `Beheer handelingen per zooltype`), the
add-handeling card, and the current-steps list with a live count (`Huidige stappen (<n>)`).
- The insole-type toggles select/deselect a type (both the remove and re-add branches), reflected via
`aria-pressed`; a new handeling defaults to all three types (`Kurk`, `Berk`, `3D`).
- Creating a handeling POSTs `/api/activities` with the trimmed name and the selected `insole_types`
subset (a deselected type is dropped from the payload); `Stap toevoegen` is disabled while the name
is blank or every type is deselected.
- Editing a handeling opens an inline editor prefilled from the row and PUTs `/api/activities/:id` with
the updated name + `insole_types`; an activity stored with no insole types pre-selects all three in the
editor; `Annuleren` exits edit mode without issuing a PUT.
- Deleting a handeling confirms first (`window.confirm`); DELETE `/api/activities/:id` fires only when
confirmed, and is skipped when cancelled.
- Reorder: ↑/↓ move a row and PUT `/api/activities/reorder` with the swapped id order; ↑ is disabled on
the first row and ↓ on the last.
- The list shows the loading placeholder (`Laden...`) while the query is pending and the empty state
(`Nog geen stappen. Voeg er een toe hierboven.`) when there are none; a query error falls back to the
same empty state (there is no dedicated error UI).
- `api/activities.ts` hooks hit the right path/method — `useActivities` → GET `/api/activities`;
`useCreateActivity` → POST; `useUpdateActivity` → PUT `/:id`; `useDeleteActivity` → DELETE `/:id`;
`useReorderActivities` → PUT `/reorder` — and every mutation invalidates the `['activities']` query on
success.
- App/nav: `App` gates on auth (no token → `Login`; a token → the authed shell with `Sidebar` + routed
screens). `Sidebar` renders the brand, the five nav links, and the signed-in email (hidden when
`useMe` has no data), and `Uitloggen` calls `signOut`.
- `Sidebar` highlights the active route via `nav-link-active` and, with `end` matching on `/`, keeps the
Live link inactive on any non-root route; clicking a nav link switches the rendered screen and moves
the highlight.
- Lifecycle (`src/screens/Activities.test.tsx`): create a handeling → edit its insole types down to a
single type → reorder it above another → delete it, with each step reflected in the refetched list;
the Sidebar test (`src/components/Sidebar.test.tsx`) covers navigation switching screens.
## Stopwatch + reconcile — worker client
Src covered (worker `apps/worker`): `src/screens/Stopwatch.tsx` and `src/lib/stopwatch.ts` — both at
100% stmts/branch/funcs/lines. Five `c8 ignore`s with reasons in `Stopwatch.tsx`: the
`paused_seconds ?? 0` coalesce in the reconcile adopt (the contract types `paused_seconds` as
`z.number().int()`, never null) and the four early-return guards in `handleSelectZool`, `handleStart`,
`handleStop`, and `handleDiscard` — each unreachable because the button that triggers it is
disabled or not rendered in exactly that state (and the `handleStart` guard also narrows the type).
Guaranteed behaviors:
- `formatTime` renders zero-padded `HH:MM:SS`, floors a negative total at `00:00:00`, and lets hours
exceed two digits; `elapsedSeconds` returns `(now − start − paused)` floored at 0 (paused time
excluded).
- The three sections (`Type zool` / `Type handeling` / `Aantal zolen`) and the Dutch Start button
render; the zool segmented control offers `Kurk` / `Berk` / `3D` with `Kurk` selected by default.
- Choosing a zooltype filters the handeling `<select>` to activities whose `insole_types` include that
type and clears any previously chosen handeling; picking the empty option (`Kies een handeling...`)
also clears the chosen handeling.
- Start is disabled until a handeling is chosen; starting fires `useStartSession` with
`{ activity_id, insole_type, pair_count }` from the current selection.
- Pair count: the stepper `+`/`−` buttons adjust the count with a floor of 1 (the `−` button is
disabled at 1); the free-typed field mirrors the raw text but only adopts a parsed value greater
than 0 (a non-positive or non-numeric entry keeps the last valid count).
- The stopwatch display is tappable: while idle with a handeling chosen it starts the session (idle
tap with no handeling is a no-op); while running it pauses via `usePauseSession`; while paused it
resumes via `useResumeSession` (folding the just-finished pause span into the local accumulator).
- On a successful start the screen adopts the returned session's running state (id, start time, cleared
pause); a paused server session is recovered into the paused UI (`Gepauzeerd — tik om te hervatten`).
- Stop (`Stop & Opslaan`) and discard both reset the timer to idle on success; a `409` (already closed
server-side) on either resets locally instead of leaving the timer stuck.
- Discard is a double-tap: the first `Annuleren` tap arms it (`Nogmaals tikken ter bevestiging`) and the
second confirms and fires `useDiscardSession`; the arm window lapses after 3s back to `Annuleren`, and
a pending arm timer is cleared both on reconcile-reset and on unmount.
- Reconcile against the active-sessions poll: while idle with a server-active session, the screen adopts
it (phone-died / resume-elsewhere); the running-vs-server check keys on session id.
- Lifecycle (`src/screens/Stopwatch.test.tsx`): running locally after a successful start, when the
active-sessions poll returns a list **without** the local session (only another worker's), the timer
resets to idle and the `Deze sessie is door de beheerder gestopt.` notice shows.
## Admin lib/api (fetch wrapper)
Src covered: `apps/admin/src/lib/api.ts` — 100% stmts/branch/funcs/lines. (Filled in Phase C —
this base fetch helper fell between the D-domains, which covered the per-resource `api/*.ts`.)
Guaranteed behaviors:
- `apiFetch` attaches `Authorization: Bearer <token>` when a token is stored, and omits it when not.
- It sets `Content-Type: application/json` when a body is present and none was set, and never
overrides an explicitly provided `Content-Type`.
- It throws `ApiError` (carrying the HTTP status) on any non-2xx response, and returns `undefined`
for an empty 2xx body (otherwise the parsed JSON).
- `signIn` posts to `/api/auth/sign-in/email`, stores the token from the `set-auth-token` header,
throws `ApiError` on rejected credentials, and throws `ApiError(500)` when the header is missing.

View File

@@ -208,20 +208,30 @@ Each phase keeps the system working and is its own spec → plan → build cycle
User management likewise consumes the existing better-auth `/api/auth/admin/*` endpoints, and the
live view + reports consume `/api/admin/sessions[/active]`. _Done when:_ an admin can see who's
working now, manage users **and activities**, and hand-correct a session.
- **Phase 4 — Workbench scanning.** QR at the bench → select workbench/activity, with
manual selection fallback. _Done when:_ scanning a bench QR pre-fills the session.
- **Phase 4 — Workbench scanning.** ✅ **Descoped / done (2026-07-22).** The product owner
confirmed the QR is just a printed code that opens the app URL — produced by an external
QR tool, no in-app camera scan, no `workbenches` entity, no session pre-fill. The existing
manual picker (Type zool → handeling → aantal zolen) already starts the session. Nothing to
build in this repo; the `workbenches` table/`workbench_id` sketched in §7 is not implemented.
- **Phase 5 — Polish & deploy.** Reporting niceties, dependency slimming, push the
container to a chosen cloud host.
> **Testing & hardening (2026-07-22, Plane SL-70).** Per-domain test safety net established
> across all three workspaces: `@vitest/coverage-v8` wired in each, a `resetDb()` per-test DB
> isolation helper, and a gap-fill + one lifecycle test per domain. **100% stmts/branch/funcs**
> everywhere (api/worker/admin); **383 tests** (was 183); zero bugs found. Guaranteed behaviors
> per domain are recorded in `docs/reference/domain-test-checklist.md` (the living
> definition-of-done). See `docs/sessions/2026-07-22-per-domain-test-safety-net.md`.
## 10. Open questions & assumptions (confirm as we go)
- **Scanning = QR codes** on benches, read by the phone camera (no special hardware),
always with manual fallback. _(Assumption.)_
- **Scanning = QR codes** — ✅ resolved (2026-07-22): the QR only opens the app URL (external
tool), not an in-app camera scan. See Phase 4.
- **Start fresh, no data migration.** Assumes no live workshop data/users worth
preserving in the friend's platform instance. If there are, they must be **exported
from the friend's Create account** — unreachable from this repo.
- **Workbench↔activity mapping** is hardcoded/seeded for now; real mapping TBD with the
friend.
- **Workbench↔activity mapping** — ✅ moot (2026-07-22): no workbench entity is built (Phase 4
descoped).
- **Deployment host** is deferred (Phase 5); the dockerized stack keeps options open.
- **Domain term:** "insole" / orthotic; Dutch UI (`Type zool`, `handeling`, `aantal
zolen`, etc.).

View File

@@ -0,0 +1,81 @@
# Session: 2026-07-22 — Per-domain test safety net
## Goal
Give every domain across `apps/api`, `apps/worker`, and `apps/admin` a measured, near-100%
test safety net (coverage tooling + gap-fill + one lifecycle test per domain), and shake out
bugs in the process. Spec: `docs/superpowers/specs/2026-07-22-per-domain-test-safety-net-design.md`;
plan: `docs/superpowers/plans/2026-07-22-per-domain-test-safety-net.md`. Tracked as Plane epic **SL-70**.
## Approach
Brainstorm → spec → plan → execution. Decisions: full safety net (tooling + gap-fill + lifecycle
tests + a living behaviors checklist), all three layers equal depth, bugs logged to Plane with
trivial fixes inline. Coverage target: near-100% per domain, no silent gaps (`/* c8 ignore */`
with a reason for genuinely unreachable/defensive lines).
- **Phase A (inline):** coverage tooling (`@vitest/coverage-v8` + `test:coverage` in all three
workspaces, `all: true`, text + json-summary), a `resetDb()` helper for per-test DB isolation
(fixes the accumulate-and-scope fragility; proved on the sessions suite), the Plane epic, and
the checklist-doc skeleton with the baseline. Commits `3c79e51`, `f872daf`, `0564fab`.
- **Phase B (Workflow, background):** 18 domains, strictly sequential — one focused
`general-purpose` agent per domain following the Domain Procedure (measure coverage → fill
gaps → add the domain's lifecycle test → near-100% → oxfmt/oxlint → append checklist section →
commit). Sequential because all agents share one working tree, one SQLite test DB, and one git
index — parallel test-runs/commits would corrupt each other.
- **Phase C (inline):** independent verification + synthesis (this session's author, not the
agents' self-reports).
## Execution — what actually happened
The Workflow hit two **transient network outages** (not test failures):
1. First run: A0–A6 (the first 7 API domains) completed and committed; then a sustained
`SSL certificate hostname mismatch` connection error killed A7 and every agent after it (12
failures). The ticktick MCP dropped at the same time — a network blip, not a code problem.
2. Resumed via `Workflow({resumeFromRunId})`: A0–A6 replayed from cache, A7 + all clients ran
live. 18/19 completed; **W2 (Stopwatch)** dropped with `Connection closed mid-response`.
The tree was left clean (no partial commit).
3. W2 finished by a single focused fallback `Agent` (avoids the resume re-running the
already-committed suffix). Commit `a30441b`.
Phase C then found one file that had fallen **between** the admin D-domains — `apps/admin/src/lib/api.ts`
(the base fetch/`ApiError` wrapper, 34% covered; the D-tasks covered the per-resource `api/*.ts`
but not the shared helper). Closed inline mirroring the worker's `api.test.ts`. Commit
`test(admin): cover lib/api fetch wrapper`.
## Outcome
**All three workspaces at 100% statements / branches / functions / lines.**
| Workspace | Stmts | Branch | Funcs | Tests (before → after) |
| --------- | ----- | ------ | ----- | ---------------------- |
| api | 100 | 100 | 100 | 103 → 175 |
| worker | 100 | 100 | 100 | 30 → 79 |
| admin | 100 | 100 | 100 | 50 → 129 |
- **383 tests total** (was 183), all green. Typechecks clean in all three; `npx oxlint` clean.
- **One lifecycle/integration test per domain** where the plan called for it (API integration
tests live in `apps/api/test/integration/`).
- **Zero bugs found.** Every added test locks in already-correct behavior — a good signal for a
greenfield build that had been TDD'd from the start. Genuinely unreachable lines
(`?? 0` guards behind `NOT NULL DEFAULT 0` columns, CLI direct-run blocks, disabled-control
early returns) are `/* c8 ignore */`'d with reasons, not left silent.
- **Harness improvement:** `resetDb()` gives per-test isolation; new API domain suites use
`beforeEach(resetDb)` so they assert exact counts without dodging other suites' rows.
- Living **`docs/reference/domain-test-checklist.md`** records the guaranteed behaviors per domain
— the definition-of-done future work checks against.
## Verification (independent of the agents' self-reports)
- `yarn workspace @solelog/{api,worker,admin} test:coverage` → 175 / 79 / 129 tests, all green,
100% (api, worker) and 100% (admin after the Phase C gap-closure).
- Three typechecks clean; `npx oxlint` exit 0; `git status` clean.
## Tracking / next
- Plane epic **SL-70** → Done. No bug tickets filed (none found).
- `main` is well ahead of origin — the maintainer pushes from their terminal (`git push origin main`).
- Next per the roadmap: **Phase 5 — Polish & deploy** (hosting already handled on the maintainer's
machine), or the Plane backlog (the "data not visible after reopening browser / relogin" bug looks
highest-value).

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

View File

@@ -0,0 +1,136 @@
# Design — Per-domain test safety net
**Date:** 2026-07-22
**Status:** Approved (brainstorming) → ready for implementation plan
**Goal:** Establish a per-domain test safety net across all three workspaces so future
changes can't silently break existing behavior, and shake out latent bugs in the process.
## Decisions (from brainstorming)
1. **Deliverable = full safety net.** Coverage tooling + per-domain gap-fill (edge / error /
authz / regression cases) + one full-lifecycle integration test per domain + a living
"guaranteed behaviors" checklist doc.
2. **Scope = all three layers, equal depth.** `apps/api`, `apps/worker`, `apps/admin`.
3. **Bug handling = log in Plane + fix trivial inline.** Every finding becomes a Plane work
item; small/safe fixes land inline in the same pass; risky / behavior-changing ones get
their own ticket and are deferred (flagged as such).
4. **Target near-100% coverage per domain** (the working bar). Every remaining uncovered
line is either tested or explicitly annotated as defensive/unreachable — no silent gaps.
A CI threshold *gate* (build fails under X%) is still deferred; the target is enforced by
review for now.
## Current baseline (2026-07-22)
| Workspace | Test files | Tests | Status |
| ----------- | ---------- | ----- | ------- |
| `apps/api` | 15 | 103 | ✅ green |
| `apps/worker` | 8 | 30 | ✅ green |
| `apps/admin` | 11 | 50 | ✅ green |
- No `@vitest/coverage-v8` in any workspace — untested paths are currently guesswork.
- **API harness:** real file DB (`file:./.tmp/test.db`), migrated once per run, serial
(`fileParallelism: false`). Helpers in `apps/api/test/helpers.ts`: `createTestUser`,
`authToken`, `bearer`, `seedActivity`. The DB is reset **once per run, not per file**, so
domain state accumulates across files — this already caused a "test bleed" bug (tests had
to scope queries to avoid each other's rows). This fragility is fixed as part of this work.
- **Client harness:** jsdom + Testing Library, per-workspace `src/test/setup.ts`.
## Work
### 1. Coverage tooling (first — turns guesswork into a measured gap list)
- Add `@vitest/coverage-v8` (dev) + a `test:coverage` script to all three workspaces.
- Config per workspace: include `src/**`; exclude tests, config files, and generated /
non-logic files (`src/db/schema.ts`, `*.d.ts`, `main.tsx`, `vite-env.d.ts`).
- Record a **baseline coverage % per domain** so every gap filled is measured, not guessed.
- **Working target: near-100% per domain.** Drive each domain's coverage up until the only
remainder is genuinely defensive / unreachable code, which is annotated (`/* c8 ignore */`
with a reason) rather than left as a silent gap. A CI *gate* is still deferred — enforce by
review for now.
### 2. Fix the API harness fragility (prerequisite for reliable domain isolation)
- Add a `resetDb()` helper (truncate `work_sessions`, `activities`, and the better-auth
tables `session`, `account`, `user`, `verification`) usable in `beforeEach` for domain
suites, so each domain's tests are isolated instead of inheriting accumulated state.
- Migration still runs once per run (top-level, before the DB file is opened/locked — the
existing Windows EBUSY constraint in `setup.ts` stays respected).
- Domain suites that adopt `resetDb()` can then assert exact counts without dodging other
suites' rows. Existing suites are migrated to it where it removes a scoping workaround.
### 3. Per-domain audit + fill (equal depth across all three layers)
For **each** domain: enumerate guaranteed behaviors (happy path + edges) → diff against
existing tests + coverage → add missing **edge / error / authz / regression** tests → add
**one full-lifecycle integration test** (§4) → record the domain's behavior checklist (§6).
**API (7 domains):**
- **Auth & `/me`** — sign-in/out, bearer token, `/api/me` role field, CORS.
- **Activities** — CRUD, admin-gated writes, reorder, `insole_types` subset handling.
- **Work-sessions (worker)** — start / pause / resume / stop / discard, active list,
history, worked-time-excludes-paused, per-user scoping (a worker never sees another's).
- **Worker export / CSV** — `/api/export`, `buildSessionsCsv` byte-shape.
- **Admin sessions** — cross-user views, active view, manual create/edit, stop/discard,
users roster.
- **Reports + admin export** — `/api/admin/report` aggregation & filters; `/api/admin/export`
all-users CSV.
- **Admin users** — create / set role / reset password / deactivate / reactivate + lockout
guards (no self-deactivate/demote, last-active-admin invariant, banned-admin gate).
**Worker (5 domains):** Auth (context / storage / login) · Stopwatch lifecycle + server
reconcile · History · Account / logout · lib (`api`/`ApiError`, `stopwatch` math).
**Admin (6 domains):** Auth (admin-only gate) · Live view · Sessions + SessionForm ·
Reports + date-range · Users · Activities.
### 4. Lifecycle integration tests (where interaction bugs hide)
One per domain, walking the real workflow. Anchor examples:
- **Worker session (API):** seed activity → start → pause → resume → stop → assert
worked-time excludes paused → appears in history → CSV row correct.
- **Admin ↔ worker (API):** worker starts → admin stops it → session lands in report +
export with correct totals.
- **User lifecycle (API):** admin creates worker → worker signs in → admin deactivates →
sign-in blocked **and** live bearer token revoked → reactivate → sign-in works again.
- **Stopwatch reconcile (worker):** running locally → admin stops server-side → next poll
clears the stuck timer (the reconcile path in `Stopwatch.tsx`).
### 5. Bugs → Plane
- New parent work item: **"Testing & hardening — per-domain safety net"** (project SoleLog,
id `1956d3d3-15ed-4850-93d8-96d5fb17f213`).
- One child per domain (audit+fill task), plus one child per bug found.
- Trivial / safe fixes land inline in the same pass; risky / behavior-changing fixes get
their own ticket and stay deferred, flagged explicitly.
### 6. Deliverables
- Coverage wired in all three workspaces; baseline recorded; all suites green.
- Gap tests + one lifecycle test per domain.
- `docs/` doc: **per-domain "guaranteed behaviors" checklist** — the living
definition-of-done future work checks against.
- Plane epic + per-domain tasks + logged bugs.
### 7. Execution vehicle
Large, parallelizable sweep → the implementation plan is structured to **run as a Workflow**:
fan out one agent per domain (audit + fill), verify, then synthesize — rather than a serial
slog. Bugs found during a domain's pass are reported structurally and triaged per §5.
## Out of scope
- A CI coverage-threshold gate that fails the build (deferred; the near-100% target is
enforced by review for now, not by CI).
- E2E browser automation (Playwright/Cypress) — the client integration tests stay at the
Testing-Library level.
- Load/performance testing.
- Any Phase 5 deploy work (hosting is already handled on the maintainer's machine).
## Non-negotiables (not simplified away)
- Authz / role-scoping paths get explicit tests (worker-can't-see-others, admin-only writes,
lockout guards) — these are trust boundaries.
- Error/409/401/403 paths are covered, not just happy paths.
- No test asserts against another test's accumulated rows (harness isolation, §2).

503
yarn.lock
View File

@@ -12,6 +12,16 @@ __metadata:
languageName: node
linkType: hard
"@ampproject/remapping@npm:^2.3.0":
version: 2.3.0
resolution: "@ampproject/remapping@npm:2.3.0"
dependencies:
"@jridgewell/gen-mapping": "npm:^0.3.5"
"@jridgewell/trace-mapping": "npm:^0.3.24"
checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed
languageName: node
linkType: hard
"@asamuzakjp/css-color@npm:^3.2.0":
version: 3.2.0
resolution: "@asamuzakjp/css-color@npm:3.2.0"
@@ -160,7 +170,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.29.7":
"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.25.4, @babel/parser@npm:^7.29.7":
version: 7.29.7
resolution: "@babel/parser@npm:7.29.7"
dependencies:
@@ -226,7 +236,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7":
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.25.4, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7":
version: 7.29.7
resolution: "@babel/types@npm:7.29.7"
dependencies:
@@ -236,6 +246,13 @@ __metadata:
languageName: node
linkType: hard
"@bcoe/v8-coverage@npm:^1.0.2":
version: 1.0.2
resolution: "@bcoe/v8-coverage@npm:1.0.2"
checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3
languageName: node
linkType: hard
"@better-auth/core@npm:1.6.18":
version: 1.6.18
resolution: "@better-auth/core@npm:1.6.18"
@@ -1118,6 +1135,20 @@ __metadata:
languageName: node
linkType: hard
"@isaacs/cliui@npm:^8.0.2":
version: 8.0.2
resolution: "@isaacs/cliui@npm:8.0.2"
dependencies:
string-width: "npm:^5.1.2"
string-width-cjs: "npm:string-width@^4.2.0"
strip-ansi: "npm:^7.0.1"
strip-ansi-cjs: "npm:strip-ansi@^6.0.1"
wrap-ansi: "npm:^8.1.0"
wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0"
checksum: 10c0/b1bf42535d49f11dc137f18d5e4e63a28c5569de438a221c369483731e9dac9fb797af554e8bf02b6192d1e5eba6e6402cf93900c3d0ac86391d00d04876789e
languageName: node
linkType: hard
"@isaacs/fs-minipass@npm:^4.0.0":
version: 4.0.1
resolution: "@isaacs/fs-minipass@npm:4.0.1"
@@ -1127,6 +1158,13 @@ __metadata:
languageName: node
linkType: hard
"@istanbuljs/schema@npm:^0.1.2":
version: 0.1.6
resolution: "@istanbuljs/schema@npm:0.1.6"
checksum: 10c0/bb0d370bf3dd454d2f37f1bccb8921e2da99adacef2da56ef47850e25d7a4de69cf639ead8c189755aef38921369024b4afea3535a5c2ac9082b3e1171bcbc3a
languageName: node
linkType: hard
"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5":
version: 0.3.13
resolution: "@jridgewell/gen-mapping@npm:0.3.13"
@@ -1161,7 +1199,7 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28":
"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28, @jridgewell/trace-mapping@npm:^0.3.31":
version: 0.3.31
resolution: "@jridgewell/trace-mapping@npm:0.3.31"
dependencies:
@@ -1572,6 +1610,13 @@ __metadata:
languageName: node
linkType: hard
"@pkgjs/parseargs@npm:^0.11.0":
version: 0.11.0
resolution: "@pkgjs/parseargs@npm:0.11.0"
checksum: 10c0/5bd7576bb1b38a47a7fc7b51ac9f38748e772beebc56200450c4a817d712232b8f1d3ef70532c80840243c657d491cf6a6be1e3a214cff907645819fdc34aadd
languageName: node
linkType: hard
"@remix-run/router@npm:1.23.3":
version: 1.23.3
resolution: "@remix-run/router@npm:1.23.3"
@@ -1774,6 +1819,7 @@ __metadata:
"@types/react": "npm:^18.3.0"
"@types/react-dom": "npm:^18.3.0"
"@vitejs/plugin-react": "npm:^4.3.0"
"@vitest/coverage-v8": "npm:^3"
jsdom: "npm:^25.0.0"
react: "npm:^18.3.1"
react-dom: "npm:^18.3.1"
@@ -1791,6 +1837,7 @@ __metadata:
"@hono/node-server": "npm:^1.13.7"
"@libsql/client": "npm:^0.14.0"
"@solelog/shared": "workspace:*"
"@vitest/coverage-v8": "npm:^3"
better-auth: "npm:^1.1.7"
drizzle-kit: "npm:^0.30.1"
drizzle-orm: "npm:^0.36.4"
@@ -1823,6 +1870,7 @@ __metadata:
"@types/react": "npm:^18.3.0"
"@types/react-dom": "npm:^18.3.0"
"@vitejs/plugin-react": "npm:^4.3.0"
"@vitest/coverage-v8": "npm:^3"
jsdom: "npm:^25.0.0"
react: "npm:^18.3.1"
react-dom: "npm:^18.3.1"
@@ -2049,6 +2097,33 @@ __metadata:
languageName: node
linkType: hard
"@vitest/coverage-v8@npm:^3":
version: 3.2.7
resolution: "@vitest/coverage-v8@npm:3.2.7"
dependencies:
"@ampproject/remapping": "npm:^2.3.0"
"@bcoe/v8-coverage": "npm:^1.0.2"
ast-v8-to-istanbul: "npm:^0.3.3"
debug: "npm:^4.4.1"
istanbul-lib-coverage: "npm:^3.2.2"
istanbul-lib-report: "npm:^3.0.1"
istanbul-lib-source-maps: "npm:^5.0.6"
istanbul-reports: "npm:^3.1.7"
magic-string: "npm:^0.30.17"
magicast: "npm:^0.3.5"
std-env: "npm:^3.9.0"
test-exclude: "npm:^7.0.1"
tinyrainbow: "npm:^2.0.0"
peerDependencies:
"@vitest/browser": 3.2.7
vitest: 3.2.7
peerDependenciesMeta:
"@vitest/browser":
optional: true
checksum: 10c0/ce0fbb0e54d67d24b8be289b93d72baaade5bd2cf3a6bed7bb3626a4b0c8262c0ec436059cf7a17d33701d6094db90cbb0aa73768e028eb1f75821e8f7429a94
languageName: node
linkType: hard
"@vitest/expect@npm:3.2.6":
version: 3.2.6
resolution: "@vitest/expect@npm:3.2.6"
@@ -2153,6 +2228,22 @@ __metadata:
languageName: node
linkType: hard
"ansi-regex@npm:^6.2.2":
version: 6.2.2
resolution: "ansi-regex@npm:6.2.2"
checksum: 10c0/05d4acb1d2f59ab2cf4b794339c7b168890d44dda4bf0ce01152a8da0213aca207802f930442ce8cd22d7a92f44907664aac6508904e75e038fa944d2601b30f
languageName: node
linkType: hard
"ansi-styles@npm:^4.0.0":
version: 4.3.0
resolution: "ansi-styles@npm:4.3.0"
dependencies:
color-convert: "npm:^2.0.1"
checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041
languageName: node
linkType: hard
"ansi-styles@npm:^5.0.0":
version: 5.2.0
resolution: "ansi-styles@npm:5.2.0"
@@ -2160,6 +2251,13 @@ __metadata:
languageName: node
linkType: hard
"ansi-styles@npm:^6.1.0":
version: 6.2.3
resolution: "ansi-styles@npm:6.2.3"
checksum: 10c0/23b8a4ce14e18fb854693b95351e286b771d23d8844057ed2e7d083cd3e708376c3323707ec6a24365f7d7eda3ca00327fe04092e29e551499ec4c8b7bfac868
languageName: node
linkType: hard
"aria-query@npm:5.3.0":
version: 5.3.0
resolution: "aria-query@npm:5.3.0"
@@ -2183,6 +2281,17 @@ __metadata:
languageName: node
linkType: hard
"ast-v8-to-istanbul@npm:^0.3.3":
version: 0.3.12
resolution: "ast-v8-to-istanbul@npm:0.3.12"
dependencies:
"@jridgewell/trace-mapping": "npm:^0.3.31"
estree-walker: "npm:^3.0.3"
js-tokens: "npm:^10.0.0"
checksum: 10c0/bad6ba222b1073c165c8d65dbf366193d4a90536dabe37f93a3df162269b1c9473975756e4c048f708c235efccc26f8e5321c547b7e9563b64b21b2e0f27cbc9
languageName: node
linkType: hard
"async-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-function@npm:1.0.0"
@@ -2204,6 +2313,20 @@ __metadata:
languageName: node
linkType: hard
"balanced-match@npm:^1.0.0":
version: 1.0.2
resolution: "balanced-match@npm:1.0.2"
checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee
languageName: node
linkType: hard
"balanced-match@npm:^4.0.2":
version: 4.0.4
resolution: "balanced-match@npm:4.0.4"
checksum: 10c0/07e86102a3eb2ee2a6a1a89164f29d0dbaebd28f2ca3f5ca786f36b8b23d9e417eb3be45a4acf754f837be5ac0a2317de90d3fcb7f4f4dc95720a1f36b26a17b
languageName: node
linkType: hard
"baseline-browser-mapping@npm:^2.10.12":
version: 2.10.37
resolution: "baseline-browser-mapping@npm:2.10.37"
@@ -2314,6 +2437,24 @@ __metadata:
languageName: node
linkType: hard
"brace-expansion@npm:^2.0.2":
version: 2.1.2
resolution: "brace-expansion@npm:2.1.2"
dependencies:
balanced-match: "npm:^1.0.0"
checksum: 10c0/5442ecab84045d21826268bc56c81a6ef4215327ee1f5ee153f67928e93f7a915d130ecec9966623143277fa3cce036ebb50020024bcc4d78853cdd6af9a19f8
languageName: node
linkType: hard
"brace-expansion@npm:^5.0.5":
version: 5.0.7
resolution: "brace-expansion@npm:5.0.7"
dependencies:
balanced-match: "npm:^4.0.2"
checksum: 10c0/4769109c3c082de178e449a371bcad50d51ab468f644bce2dd9188efe0cf0a080ed102105d7fc8577382cedc45bad7e6443a91bf3d8102264ee8cf927dbaf205
languageName: node
linkType: hard
"browserslist@npm:^4.24.0":
version: 4.28.2
resolution: "browserslist@npm:4.28.2"
@@ -2387,6 +2528,22 @@ __metadata:
languageName: node
linkType: hard
"color-convert@npm:^2.0.1":
version: 2.0.1
resolution: "color-convert@npm:2.0.1"
dependencies:
color-name: "npm:~1.1.4"
checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7
languageName: node
linkType: hard
"color-name@npm:~1.1.4":
version: 1.1.4
resolution: "color-name@npm:1.1.4"
checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95
languageName: node
linkType: hard
"combined-stream@npm:^1.0.8":
version: 1.0.8
resolution: "combined-stream@npm:1.0.8"
@@ -2403,6 +2560,17 @@ __metadata:
languageName: node
linkType: hard
"cross-spawn@npm:^7.0.6":
version: 7.0.6
resolution: "cross-spawn@npm:7.0.6"
dependencies:
path-key: "npm:^3.1.0"
shebang-command: "npm:^2.0.0"
which: "npm:^2.0.1"
checksum: 10c0/053ea8b2135caff68a9e81470e845613e374e7309a47731e81639de3eaeb90c3d01af0e0b44d2ab9d50b43467223b88567dfeb3262db942dc063b9976718ffc1
languageName: node
linkType: hard
"css.escape@npm:^1.5.1":
version: 1.5.1
resolution: "css.escape@npm:1.5.1"
@@ -2444,7 +2612,7 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.4, debug@npm:^4.4.1":
"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.4, debug@npm:^4.4.1":
version: 4.4.3
resolution: "debug@npm:4.4.3"
dependencies:
@@ -2633,6 +2801,13 @@ __metadata:
languageName: node
linkType: hard
"eastasianwidth@npm:^0.2.0":
version: 0.2.0
resolution: "eastasianwidth@npm:0.2.0"
checksum: 10c0/26f364ebcdb6395f95124fda411f63137a4bfb5d3a06453f7f23dfe52502905bd84e0488172e0f9ec295fdc45f05c23d5d91baf16bd26f0fe9acd777a188dc39
languageName: node
linkType: hard
"electron-to-chromium@npm:^1.5.328":
version: 1.5.375
resolution: "electron-to-chromium@npm:1.5.375"
@@ -2640,6 +2815,20 @@ __metadata:
languageName: node
linkType: hard
"emoji-regex@npm:^8.0.0":
version: 8.0.0
resolution: "emoji-regex@npm:8.0.0"
checksum: 10c0/b6053ad39951c4cf338f9092d7bfba448cdfd46fe6a2a034700b149ac9ffbc137e361cbd3c442297f86bed2e5f7576c1b54cc0a6bf8ef5106cc62f496af35010
languageName: node
linkType: hard
"emoji-regex@npm:^9.2.2":
version: 9.2.2
resolution: "emoji-regex@npm:9.2.2"
checksum: 10c0/af014e759a72064cf66e6e694a7fc6b0ed3d8db680427b021a89727689671cefe9d04151b2cad51dbaf85d5ba790d061cd167f1cf32eb7b281f6368b3c181639
languageName: node
linkType: hard
"entities@npm:^6.0.0":
version: 6.0.1
resolution: "entities@npm:6.0.1"
@@ -3101,6 +3290,16 @@ __metadata:
languageName: node
linkType: hard
"foreground-child@npm:^3.1.0":
version: 3.3.1
resolution: "foreground-child@npm:3.3.1"
dependencies:
cross-spawn: "npm:^7.0.6"
signal-exit: "npm:^4.0.1"
checksum: 10c0/8986e4af2430896e65bc2788d6679067294d6aee9545daefc84923a0a4b399ad9c7a3ea7bd8c0b2b80fdf4a92de4c69df3f628233ff3224260e9c1541a9e9ed3
languageName: node
linkType: hard
"form-data@npm:^4.0.0":
version: 4.0.6
resolution: "form-data@npm:4.0.6"
@@ -3219,6 +3418,22 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:^10.4.1":
version: 10.5.0
resolution: "glob@npm:10.5.0"
dependencies:
foreground-child: "npm:^3.1.0"
jackspeak: "npm:^3.1.2"
minimatch: "npm:^9.0.4"
minipass: "npm:^7.1.2"
package-json-from-dist: "npm:^1.0.0"
path-scurry: "npm:^1.11.1"
bin:
glob: dist/esm/bin.mjs
checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828
languageName: node
linkType: hard
"gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
@@ -3233,6 +3448,13 @@ __metadata:
languageName: node
linkType: hard
"has-flag@npm:^4.0.0":
version: 4.0.0
resolution: "has-flag@npm:4.0.0"
checksum: 10c0/2e789c61b7888d66993e14e8331449e525ef42aac53c627cc53d1c3334e768bcb6abdc4f5f0de1478a25beec6f0bd62c7549058b7ac53e924040d4f301f02fd1
languageName: node
linkType: hard
"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0":
version: 1.1.0
resolution: "has-symbols@npm:1.1.0"
@@ -3274,6 +3496,13 @@ __metadata:
languageName: node
linkType: hard
"html-escaper@npm:^2.0.0":
version: 2.0.2
resolution: "html-escaper@npm:2.0.2"
checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0
languageName: node
linkType: hard
"http-proxy-agent@npm:^7.0.2":
version: 7.0.2
resolution: "http-proxy-agent@npm:7.0.2"
@@ -3310,6 +3539,13 @@ __metadata:
languageName: node
linkType: hard
"is-fullwidth-code-point@npm:^3.0.0":
version: 3.0.0
resolution: "is-fullwidth-code-point@npm:3.0.0"
checksum: 10c0/bb11d825e049f38e04c06373a8d72782eee0205bda9d908cc550ccb3c59b99d750ff9537982e01733c1c94a58e35400661f57042158ff5e8f3e90cf936daf0fc
languageName: node
linkType: hard
"is-potential-custom-element-name@npm:^1.0.1":
version: 1.0.1
resolution: "is-potential-custom-element-name@npm:1.0.1"
@@ -3317,6 +3553,13 @@ __metadata:
languageName: node
linkType: hard
"isexe@npm:^2.0.0":
version: 2.0.0
resolution: "isexe@npm:2.0.0"
checksum: 10c0/228cfa503fadc2c31596ab06ed6aa82c9976eec2bfd83397e7eaf06d0ccf42cd1dfd6743bf9aeb01aebd4156d009994c5f76ea898d2832c1fe342da923ca457d
languageName: node
linkType: hard
"isexe@npm:^3.1.1":
version: 3.1.5
resolution: "isexe@npm:3.1.5"
@@ -3331,6 +3574,58 @@ __metadata:
languageName: node
linkType: hard
"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2":
version: 3.2.2
resolution: "istanbul-lib-coverage@npm:3.2.2"
checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b
languageName: node
linkType: hard
"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1":
version: 3.0.1
resolution: "istanbul-lib-report@npm:3.0.1"
dependencies:
istanbul-lib-coverage: "npm:^3.0.0"
make-dir: "npm:^4.0.0"
supports-color: "npm:^7.1.0"
checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7
languageName: node
linkType: hard
"istanbul-lib-source-maps@npm:^5.0.6":
version: 5.0.6
resolution: "istanbul-lib-source-maps@npm:5.0.6"
dependencies:
"@jridgewell/trace-mapping": "npm:^0.3.23"
debug: "npm:^4.1.1"
istanbul-lib-coverage: "npm:^3.0.0"
checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f
languageName: node
linkType: hard
"istanbul-reports@npm:^3.1.7":
version: 3.2.0
resolution: "istanbul-reports@npm:3.2.0"
dependencies:
html-escaper: "npm:^2.0.0"
istanbul-lib-report: "npm:^3.0.0"
checksum: 10c0/d596317cfd9c22e1394f22a8d8ba0303d2074fe2e971887b32d870e4b33f8464b10f8ccbe6847808f7db485f084eba09e6c2ed706b3a978e4b52f07085b8f9bc
languageName: node
linkType: hard
"jackspeak@npm:^3.1.2":
version: 3.4.3
resolution: "jackspeak@npm:3.4.3"
dependencies:
"@isaacs/cliui": "npm:^8.0.2"
"@pkgjs/parseargs": "npm:^0.11.0"
dependenciesMeta:
"@pkgjs/parseargs":
optional: true
checksum: 10c0/6acc10d139eaefdbe04d2f679e6191b3abf073f111edf10b1de5302c97ec93fffeb2fdd8681ed17f16268aa9dd4f8c588ed9d1d3bffbbfa6e8bf897cbb3149b9
languageName: node
linkType: hard
"jose@npm:^6.1.3":
version: 6.2.3
resolution: "jose@npm:6.2.3"
@@ -3345,6 +3640,13 @@ __metadata:
languageName: node
linkType: hard
"js-tokens@npm:^10.0.0":
version: 10.0.0
resolution: "js-tokens@npm:10.0.0"
checksum: 10c0/a93498747812ba3e0c8626f95f75ab29319f2a13613a0de9e610700405760931624433a0de59eb7c27ff8836e526768fb20783861b86ef89be96676f2c996b64
languageName: node
linkType: hard
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@@ -3469,7 +3771,7 @@ __metadata:
languageName: node
linkType: hard
"lru-cache@npm:^10.4.3":
"lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3":
version: 10.4.3
resolution: "lru-cache@npm:10.4.3"
checksum: 10c0/ebd04fbca961e6c1d6c0af3799adcc966a1babe798f685bb84e6599266599cd95d94630b10262f5424539bc4640107e8a33aa28585374abf561d30d16f4b39fb
@@ -3503,6 +3805,26 @@ __metadata:
languageName: node
linkType: hard
"magicast@npm:^0.3.5":
version: 0.3.5
resolution: "magicast@npm:0.3.5"
dependencies:
"@babel/parser": "npm:^7.25.4"
"@babel/types": "npm:^7.25.4"
source-map-js: "npm:^1.2.0"
checksum: 10c0/a6cacc0a848af84f03e3f5bda7b0de75e4d0aa9ddce5517fd23ed0f31b5ddd51b2d0ff0b7e09b51f7de0f4053c7a1107117edda6b0732dca3e9e39e6c5a68c64
languageName: node
linkType: hard
"make-dir@npm:^4.0.0":
version: 4.0.0
resolution: "make-dir@npm:4.0.0"
dependencies:
semver: "npm:^7.5.3"
checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68
languageName: node
linkType: hard
"math-intrinsics@npm:^1.1.0":
version: 1.1.0
resolution: "math-intrinsics@npm:1.1.0"
@@ -3533,7 +3855,25 @@ __metadata:
languageName: node
linkType: hard
"minipass@npm:^7.0.4, minipass@npm:^7.1.2":
"minimatch@npm:^10.2.2":
version: 10.2.5
resolution: "minimatch@npm:10.2.5"
dependencies:
brace-expansion: "npm:^5.0.5"
checksum: 10c0/6bb058bd6324104b9ec2f763476a35386d05079c1f5fe4fbf1f324a25237cd4534d6813ecd71f48208f4e635c1221899bef94c3c89f7df55698fe373aaae20fd
languageName: node
linkType: hard
"minimatch@npm:^9.0.4":
version: 9.0.9
resolution: "minimatch@npm:9.0.9"
dependencies:
brace-expansion: "npm:^2.0.2"
checksum: 10c0/0b6a58530dbb00361745aa6c8cffaba4c90f551afe7c734830bd95fd88ebf469dd7355a027824ea1d09e37181cfeb0a797fb17df60c15ac174303ac110eb7e86
languageName: node
linkType: hard
"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.4, minipass@npm:^7.1.2":
version: 7.1.3
resolution: "minipass@npm:7.1.3"
checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb
@@ -3780,6 +4120,13 @@ __metadata:
languageName: node
linkType: hard
"package-json-from-dist@npm:^1.0.0":
version: 1.0.1
resolution: "package-json-from-dist@npm:1.0.1"
checksum: 10c0/62ba2785eb655fec084a257af34dbe24292ab74516d6aecef97ef72d4897310bc6898f6c85b5cd22770eaa1ce60d55a0230e150fb6a966e3ecd6c511e23d164b
languageName: node
linkType: hard
"parse5@npm:^7.1.2":
version: 7.3.0
resolution: "parse5@npm:7.3.0"
@@ -3789,6 +4136,23 @@ __metadata:
languageName: node
linkType: hard
"path-key@npm:^3.1.0":
version: 3.1.1
resolution: "path-key@npm:3.1.1"
checksum: 10c0/748c43efd5a569c039d7a00a03b58eecd1d75f3999f5a28303d75f521288df4823bc057d8784eb72358b2895a05f29a070bc9f1f17d28226cc4e62494cc58c4c
languageName: node
linkType: hard
"path-scurry@npm:^1.11.1":
version: 1.11.1
resolution: "path-scurry@npm:1.11.1"
dependencies:
lru-cache: "npm:^10.2.0"
minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0"
checksum: 10c0/32a13711a2a505616ae1cc1b5076801e453e7aae6ac40ab55b388bb91b9d0547a52f5aaceff710ea400205f18691120d4431e520afbe4266b836fadede15872d
languageName: node
linkType: hard
"pathe@npm:^2.0.3":
version: 2.0.3
resolution: "pathe@npm:2.0.3"
@@ -4099,6 +4463,15 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:^7.5.3":
version: 7.8.5
resolution: "semver@npm:7.8.5"
bin:
semver: bin/semver.js
checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c
languageName: node
linkType: hard
"set-cookie-parser@npm:^3.0.1":
version: 3.1.0
resolution: "set-cookie-parser@npm:3.1.0"
@@ -4106,6 +4479,22 @@ __metadata:
languageName: node
linkType: hard
"shebang-command@npm:^2.0.0":
version: 2.0.0
resolution: "shebang-command@npm:2.0.0"
dependencies:
shebang-regex: "npm:^3.0.0"
checksum: 10c0/a41692e7d89a553ef21d324a5cceb5f686d1f3c040759c50aab69688634688c5c327f26f3ecf7001ebfd78c01f3c7c0a11a7c8bfd0a8bc9f6240d4f40b224e4e
languageName: node
linkType: hard
"shebang-regex@npm:^3.0.0":
version: 3.0.0
resolution: "shebang-regex@npm:3.0.0"
checksum: 10c0/1dbed0726dd0e1152a92696c76c7f06084eb32a90f0528d11acd764043aacf76994b2fb30aa1291a21bd019d6699164d048286309a278855ee7bec06cf6fb690
languageName: node
linkType: hard
"shell-quote@npm:^1.8.1":
version: 1.8.4
resolution: "shell-quote@npm:1.8.4"
@@ -4120,7 +4509,14 @@ __metadata:
languageName: node
linkType: hard
"source-map-js@npm:^1.2.1":
"signal-exit@npm:^4.0.1":
version: 4.1.0
resolution: "signal-exit@npm:4.1.0"
checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83
languageName: node
linkType: hard
"source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1":
version: 1.2.1
resolution: "source-map-js@npm:1.2.1"
checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf
@@ -4158,6 +4554,46 @@ __metadata:
languageName: node
linkType: hard
"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0":
version: 4.2.3
resolution: "string-width@npm:4.2.3"
dependencies:
emoji-regex: "npm:^8.0.0"
is-fullwidth-code-point: "npm:^3.0.0"
strip-ansi: "npm:^6.0.1"
checksum: 10c0/1e525e92e5eae0afd7454086eed9c818ee84374bb80328fc41217ae72ff5f065ef1c9d7f72da41de40c75fa8bb3dee63d92373fd492c84260a552c636392a47b
languageName: node
linkType: hard
"string-width@npm:^5.0.1, string-width@npm:^5.1.2":
version: 5.1.2
resolution: "string-width@npm:5.1.2"
dependencies:
eastasianwidth: "npm:^0.2.0"
emoji-regex: "npm:^9.2.2"
strip-ansi: "npm:^7.0.1"
checksum: 10c0/ab9c4264443d35b8b923cbdd513a089a60de339216d3b0ed3be3ba57d6880e1a192b70ae17225f764d7adbf5994e9bb8df253a944736c15a0240eff553c678ca
languageName: node
linkType: hard
"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1":
version: 6.0.1
resolution: "strip-ansi@npm:6.0.1"
dependencies:
ansi-regex: "npm:^5.0.1"
checksum: 10c0/1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952
languageName: node
linkType: hard
"strip-ansi@npm:^7.0.1":
version: 7.2.0
resolution: "strip-ansi@npm:7.2.0"
dependencies:
ansi-regex: "npm:^6.2.2"
checksum: 10c0/544d13b7582f8254811ea97db202f519e189e59d35740c46095897e254e4f1aa9fe1524a83ad6bc5ad67d4dd6c0281d2e0219ed62b880a6238a16a17d375f221
languageName: node
linkType: hard
"strip-indent@npm:^3.0.0":
version: 3.0.0
resolution: "strip-indent@npm:3.0.0"
@@ -4176,6 +4612,15 @@ __metadata:
languageName: node
linkType: hard
"supports-color@npm:^7.1.0":
version: 7.2.0
resolution: "supports-color@npm:7.2.0"
dependencies:
has-flag: "npm:^4.0.0"
checksum: 10c0/afb4c88521b8b136b5f5f95160c98dee7243dc79d5432db7efc27efb219385bbc7d9427398e43dd6cc730a0f87d5085ce1652af7efbe391327bc0a7d0f7fc124
languageName: node
linkType: hard
"symbol-tree@npm:^3.2.4":
version: 3.2.4
resolution: "symbol-tree@npm:3.2.4"
@@ -4196,6 +4641,17 @@ __metadata:
languageName: node
linkType: hard
"test-exclude@npm:^7.0.1":
version: 7.0.2
resolution: "test-exclude@npm:7.0.2"
dependencies:
"@istanbuljs/schema": "npm:^0.1.2"
glob: "npm:^10.4.1"
minimatch: "npm:^10.2.2"
checksum: 10c0/b79b855af9168c6a362146015ccf40f5e3a25e307304ba9bea930818507f6319d230380d5d7b5baa659c981ccc11f1bd21b6f012f85606353dec07e02dee67c9
languageName: node
linkType: hard
"tinybench@npm:^2.9.0":
version: 2.9.0
resolution: "tinybench@npm:2.9.0"
@@ -4522,6 +4978,17 @@ __metadata:
languageName: node
linkType: hard
"which@npm:^2.0.1":
version: 2.0.2
resolution: "which@npm:2.0.2"
dependencies:
isexe: "npm:^2.0.0"
bin:
node-which: ./bin/node-which
checksum: 10c0/66522872a768b60c2a65a57e8ad184e5372f5b6a9ca6d5f033d4b0dc98aff63995655a7503b9c0a2598936f532120e81dd8cc155e2e92ed662a2b9377cc4374f
languageName: node
linkType: hard
"which@npm:^4.0.0":
version: 4.0.0
resolution: "which@npm:4.0.0"
@@ -4556,6 +5023,28 @@ __metadata:
languageName: node
linkType: hard
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version: 7.0.0
resolution: "wrap-ansi@npm:7.0.0"
dependencies:
ansi-styles: "npm:^4.0.0"
string-width: "npm:^4.1.0"
strip-ansi: "npm:^6.0.0"
checksum: 10c0/d15fc12c11e4cbc4044a552129ebc75ee3f57aa9c1958373a4db0292d72282f54373b536103987a4a7594db1ef6a4f10acf92978f79b98c49306a4b58c77d4da
languageName: node
linkType: hard
"wrap-ansi@npm:^8.1.0":
version: 8.1.0
resolution: "wrap-ansi@npm:8.1.0"
dependencies:
ansi-styles: "npm:^6.1.0"
string-width: "npm:^5.0.1"
strip-ansi: "npm:^7.0.1"
checksum: 10c0/138ff58a41d2f877eae87e3282c0630fc2789012fc1af4d6bd626eeb9a2f9a65ca92005e6e69a75c7b85a68479fe7443c7dbe1eb8fbaa681a4491364b7c55c60
languageName: node
linkType: hard
"ws@npm:^8.13.0, ws@npm:^8.18.0":
version: 8.21.0
resolution: "ws@npm:8.21.0"