import { describe, it, expect, beforeEach } from 'vitest'; import type { Hono } from 'hono'; import { createApp } from '../src/app'; import { db } from '../src/db/client'; import { user, workSessions } from '../src/db/schema'; import { eq } from 'drizzle-orm'; 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 completed( app: Hono, token: string, activityId: number, insoleType: string, durationSeconds: number, pairCount = 2, ): Promise { const startRes = await app.request('/api/sessions/start', { method: 'POST', headers: bearer(token), body: JSON.stringify({ activity_id: activityId, insole_type: insoleType, pair_count: pairCount, }), }); const started = await startRes.json(); await db .update(workSessions) .set({ startTime: new Date(Date.now() - durationSeconds * 1000) }) .where(eq(workSessions.id, started.id)); await app.request(`/api/sessions/${started.id}/stop`, { method: 'POST', headers: bearer(token) }); return started.id as number; } 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); const workerTok = await authToken(app, 'report-worker@example.com'); const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(workerTok) }); expect(res.status).toBe(403); }); it('400s on a missing or inverted range', async () => { const app = createApp(); const adminTok = await authToken(app, 'report-badrange@example.com', 'admin'); expect((await app.request('/api/admin/report', { 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/report?${inverted}`, { headers: bearer(adminTok) })).status, ).toBe(400); }); it('aggregates totals that equal the sum of each breakdown', async () => { const app = createApp(); const adminTok = await authToken(app, 'report-agg-admin@example.com', 'admin'); const workerTok = await authToken(app, 'report-agg-worker@example.com'); // 'report-agg-worker' const frezen = await seedActivity('Frezen'); const lijmen = await seedActivity('Lijmen'); await completed(app, workerTok, frezen, 'Kurk', 100, 2); await completed(app, workerTok, lijmen, 'Berk', 50, 3); const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) }); expect(res.status).toBe(200); const body = await res.json(); expect(body.totals.worked_seconds).toBe(150); expect(body.totals.pairs).toBe(5); expect(body.totals.sessions).toBe(2); const sum = (rows: { worked_seconds: number; pairs: number; sessions: number }[]) => rows.reduce( (a, r) => ({ worked_seconds: a.worked_seconds + r.worked_seconds, pairs: a.pairs + r.pairs, sessions: a.sessions + r.sessions, }), { worked_seconds: 0, pairs: 0, sessions: 0 }, ); expect(sum(body.by_worker)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 }); expect(sum(body.by_activity)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 }); expect(sum(body.by_type)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 }); expect(body.by_activity).toHaveLength(2); expect(body.by_type.map((t: { insole_type: string }) => t.insole_type).sort()).toEqual([ 'Berk', 'Kurk', ]); }); it('excludes active/discarded and respects the user and type filters', async () => { const app = createApp(); const adminTok = await authToken(app, 'report-filter-admin@example.com', 'admin'); const a = await authToken(app, 'report-filter-a@example.com'); const b = await authToken(app, 'report-filter-b@example.com'); const act = await seedActivity('Slijpen'); await completed(app, a, act, 'Kurk', 30, 2); // counts for A/Kurk await completed(app, b, act, 'Berk', 40, 2); // counts for B/Berk // A: an active session (no duration) — must be excluded. await app.request('/api/sessions/start', { method: 'POST', headers: bearer(a), body: JSON.stringify({ activity_id: act, insole_type: 'Kurk', pair_count: 2 }), }); // Resolve A's user id via the roster. const roster = await ( await app.request('/api/admin/users', { headers: bearer(adminTok) }) ).json(); const userA = roster.find((u: { email: string }) => u.email === 'report-filter-a@example.com'); const res = await app.request( `/api/admin/report?${WIDE}&user_id=${userA.id}&insole_type=Kurk`, { headers: bearer(adminTok) }, ); const body = await res.json(); expect(body.totals.sessions).toBe(1); // only A's completed Kurk session expect(body.totals.worked_seconds).toBe(30); }); it('returns zeros and empty arrays for an empty range', async () => { const app = createApp(); const adminTok = await authToken(app, 'report-empty-admin@example.com', 'admin'); const empty = `from=${new Date('1990-01-01').toISOString()}&to=${new Date('1990-02-01').toISOString()}`; const res = await app.request(`/api/admin/report?${empty}`, { headers: bearer(adminTok) }); const body = await res.json(); expect(body.totals).toEqual({ worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 }); expect(body.by_worker).toEqual([]); 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); const workerTok = await authToken(app, 'export-admin-worker@example.com'); const res = await app.request(`/api/admin/export?${WIDE}`, { headers: bearer(workerTok) }); 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'); const a = await authToken(app, 'export-allusers-a@example.com'); const b = await authToken(app, 'export-allusers-b@example.com'); const act = await seedActivity('Frezen'); await completed(app, a, act, 'Kurk', 90, 2); await completed(app, b, act, 'Berk', 60, 2); // 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); expect(res.headers.get('content-type')).toContain('text/csv'); expect(res.headers.get('content-disposition')).toMatch( /attachment; filename="solelog-report_\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}\.csv"/, ); const lines = (await res.text()).split('\n'); expect(lines[0]).toBe( '"Worker","ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"', ); expect(lines).toHaveLength(3); // header + 2 workers' sessions expect(lines.some((l) => l.includes('export-allusers-a'))).toBe(true); expect(lines.some((l) => l.includes('export-allusers-b'))).toBe(true); }); it('respects the type filter', async () => { const app = createApp(); const adminTok = await authToken(app, 'export-typefilter-admin@example.com', 'admin'); const a = await authToken(app, 'export-typefilter-a@example.com'); const act = await seedActivity('Lijmen'); await completed(app, a, act, 'Kurk', 30, 2); await completed(app, a, act, 'Berk', 40, 2); const res = await app.request(`/api/admin/export?${WIDE}&activity_id=${act}&insole_type=Berk`, { headers: bearer(adminTok), }); const lines = (await res.text()).split('\n'); expect(lines).toHaveLength(2); // header + only the Berk row expect(lines[1]).toContain('"Berk"'); }); });