diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index 4b2fa77..6b5296f 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -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); diff --git a/apps/api/test/integration/worker-session-lifecycle.test.ts b/apps/api/test/integration/worker-session-lifecycle.test.ts new file mode 100644 index 0000000..028b345 --- /dev/null +++ b/apps/api/test/integration/worker-session-lifecycle.test.ts @@ -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 + }); +}); diff --git a/apps/api/test/sessions.test.ts b/apps/api/test/sessions.test.ts index 86881b0..8d7c4ad 100644 --- a/apps/api/test/sessions.test.ts +++ b/apps/api/test/sessions.test.ts @@ -441,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'); + }); +}); diff --git a/apps/api/test/work-session.test.ts b/apps/api/test/work-session.test.ts index f899d47..4f7cadd 100644 --- a/apps/api/test/work-session.test.ts +++ b/apps/api/test/work-session.test.ts @@ -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(); + }); +}); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 1306022..f8a6482 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -90,3 +90,40 @@ Guaranteed behaviors: 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.