From bdf65ae68562e54c7de299723a40ab13ce57e26c Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 22 Jul 2026 12:41:52 +0200 Subject: [PATCH] =?UTF-8?q?test(api):=20Admin=20sessions=20=E2=80=94=20fil?= =?UTF-8?q?l=20gaps=20+=20lifecycle=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/src/routes/admin.ts | 1 + apps/api/test/admin.test.ts | 266 +++++++++++++++++- .../integration/admin-worker-session.test.ts | 68 +++++ docs/reference/domain-test-checklist.md | 33 +++ 4 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 apps/api/test/integration/admin-worker-session.test.ts diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index c037822..5f34dd8 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -350,6 +350,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); diff --git a/apps/api/test/admin.test.ts b/apps/api/test/admin.test.ts index 6662038..7fc5d41 100644 --- a/apps/api/test/admin.test.ts +++ b/apps/api/test/admin.test.ts @@ -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 { @@ -11,6 +12,29 @@ async function userIdByEmail(email: string): Promise { 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 diff --git a/apps/api/test/integration/admin-worker-session.test.ts b/apps/api/test/integration/admin-worker-session.test.ts new file mode 100644 index 0000000..3f90b07 --- /dev/null +++ b/apps/api/test/integration/admin-worker-session.test.ts @@ -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'); + }); +}); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 0919dc6..ed759a2 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -153,3 +153,36 @@ Guaranteed behaviors: 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).