diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 5f34dd8..f4008c2 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -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, diff --git a/apps/api/test/integration/report-aggregation.test.ts b/apps/api/test/integration/report-aggregation.test.ts new file mode 100644 index 0000000..24d44af --- /dev/null +++ b/apps/api/test/integration/report-aggregation.test.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/apps/api/test/report.test.ts b/apps/api/test/report.test.ts index c25beb0..24e67ec 100644 --- a/apps/api/test/report.test.ts +++ b/apps/api/test/report.test.ts @@ -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); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index ed759a2..b2fbb87 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -186,3 +186,39 @@ Guaranteed behaviors: 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__.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.