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, workSessions } from '../src/db/schema'; import { eq } from 'drizzle-orm'; async function userIdByEmail(email: string): Promise { const [row] = await db.select({ id: user.id }).from(user).where(eq(user.email, email)); if (!row) throw new Error(`no user for ${email}`); 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(); expect((await app.request('/api/admin/sessions')).status).toBe(401); expect((await app.request('/api/admin/sessions/active')).status).toBe(401); }); it('403s for a worker', async () => { const app = createApp(); const token = await authToken(app, 'admin-view-worker@example.com'); // worker expect((await app.request('/api/admin/sessions', { headers: bearer(token) })).status).toBe(403); }); it("returns ALL users' sessions for an admin, with user info", async () => { const app = createApp(); const adminTok = await authToken(app, 'admin-view-admin@example.com', 'admin'); const workerTok = await authToken(app, 'admin-view-w2@example.com'); // worker const activityId = await seedActivity('Frezen'); // 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: 2 }), }) ).json(); const res = await app.request('/api/admin/sessions', { headers: bearer(adminTok) }); expect(res.status).toBe(200); const body = await res.json(); const found = body.find((s: { id: number }) => s.id === started.id); expect(found).toBeTruthy(); expect(found.user_email).toBe('admin-view-w2@example.com'); expect(found.activity_name).toBe('Frezen'); const active = await app.request('/api/admin/sessions/active', { headers: bearer(adminTok) }); expect(active.status).toBe(200); const activeBody = await active.json(); expect(activeBody.some((s: { id: number }) => s.id === started.id)).toBe(true); }); }); describe('admin users roster', () => { it('returns id/name/email objects for an admin, ordered by name', async () => { const app = createApp(); const adminTok = await authToken(app, 'roster-admin@example.com', 'admin'); await createTestUser('roster-w1@example.com'); const res = await app.request('/api/admin/users', { headers: bearer(adminTok) }); expect(res.status).toBe(200); const body = await res.json(); expect(Array.isArray(body)).toBe(true); const found = body.find((u: { email: string }) => u.email === 'roster-w1@example.com'); expect(found).toBeTruthy(); expect(found).toHaveProperty('id'); expect(found).toHaveProperty('name'); expect(found).toHaveProperty('email'); // ordered by name ascending const names = body.map((u: { name: string }) => u.name); const sorted = [...names].sort(); expect(names).toEqual(sorted); }); it('403s for a worker', async () => { const app = createApp(); const workerTok = await authToken(app, 'roster-worker@example.com'); // worker expect((await app.request('/api/admin/users', { headers: bearer(workerTok) })).status).toBe( 403, ); }); }); describe('admin manual session create', () => { it('creates a completed manual session with duration excluding paused', async () => { const app = createApp(); const adminTok = await authToken(app, 'create-admin@example.com', 'admin'); await createTestUser('create-target@example.com'); const targetId = await userIdByEmail('create-target@example.com'); const activityId = await seedActivity('Snijden'); const start = new Date('2026-06-17T08:00:00.000Z'); const end = new Date('2026-06-17T09:00:00.000Z'); // 1h apart = 3600s 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: 3, start_time: start.toISOString(), end_time: end.toISOString(), paused_seconds: 600, notes: 'handmatig', }), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.source).toBe('manual'); expect(body.status).toBe('completed'); expect(body.duration_seconds).toBe(3000); // 3600 - 600 expect(body.paused_seconds).toBe(600); expect(body.paused_at).toBeNull(); expect(body.user_id).toBe(targetId); expect(body.activity_name).toBe('Snijden'); }); it('400s when end < start', async () => { const app = createApp(); const adminTok = await authToken(app, 'create-badtime-admin@example.com', 'admin'); await createTestUser('create-badtime-target@example.com'); const targetId = await userIdByEmail('create-badtime-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-17T09:00:00.000Z', end_time: '2026-06-17T08:00:00.000Z', }), }); expect(res.status).toBe(400); }); it('404s for an unknown user', async () => { const app = createApp(); const adminTok = await authToken(app, 'create-nouser-admin@example.com', 'admin'); const activityId = await seedActivity('Lijmen'); const res = await app.request('/api/admin/sessions', { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ user_id: 'does-not-exist', 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', }), }); expect(res.status).toBe(404); }); it('404s for an unknown activity', async () => { const app = createApp(); const adminTok = await authToken(app, 'create-noact-admin@example.com', 'admin'); await createTestUser('create-noact-target@example.com'); const targetId = await userIdByEmail('create-noact-target@example.com'); const res = await app.request('/api/admin/sessions', { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ user_id: targetId, activity_id: 999999, insole_type: 'Kurk', pair_count: 1, start_time: '2026-06-17T08:00:00.000Z', end_time: '2026-06-17T09:00:00.000Z', }), }); 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 const res = await app.request('/api/admin/sessions', { method: 'POST', headers: bearer(workerTok), body: JSON.stringify({ user_id: 'x', activity_id: 1, insole_type: 'Kurk', pair_count: 1, start_time: '2026-06-17T08:00:00.000Z', end_time: '2026-06-17T09:00:00.000Z', }), }); expect(res.status).toBe(403); }); }); describe('admin session edit', () => { it('recomputes duration on edit', async () => { const app = createApp(); const adminTok = await authToken(app, 'edit-admin@example.com', 'admin'); await createTestUser('edit-target@example.com'); const targetId = await userIdByEmail('edit-target@example.com'); const activityId = await seedActivity('Polijsten'); const created = await ( 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', paused_seconds: 0, }), }) ).json(); expect(created.duration_seconds).toBe(3600); const res = await app.request(`/api/admin/sessions/${created.id}`, { method: 'PUT', headers: bearer(adminTok), body: JSON.stringify({ activity_id: activityId, insole_type: 'Berk', pair_count: 4, start_time: '2026-06-17T08:00:00.000Z', end_time: '2026-06-17T10:00:00.000Z', // now 2h paused_seconds: 300, notes: 'gecorrigeerd', status: 'completed', }), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.duration_seconds).toBe(7200 - 300); expect(body.insole_type).toBe('Berk'); expect(body.pair_count).toBe(4); expect(body.notes).toBe('gecorrigeerd'); }); it('sets duration null when end_time is null (active)', async () => { const app = createApp(); const adminTok = await authToken(app, 'edit-active-admin@example.com', 'admin'); await createTestUser('edit-active-target@example.com'); const targetId = await userIdByEmail('edit-active-target@example.com'); const activityId = await seedActivity('Stikken'); const created = await ( 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(); 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: null, paused_seconds: 0, notes: null, status: 'active', }), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.duration_seconds).toBeNull(); expect(body.end_time).toBeNull(); expect(body.status).toBe('active'); }); it('400s when end < start on edit', async () => { const app = createApp(); const adminTok = await authToken(app, 'edit-badtime-admin@example.com', 'admin'); await createTestUser('edit-badtime-target@example.com'); const targetId = await userIdByEmail('edit-badtime-target@example.com'); const activityId = await seedActivity('Wassen'); const created = await ( 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(); 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-17T09:00:00.000Z', end_time: '2026-06-17T08:00:00.000Z', paused_seconds: 0, notes: null, status: 'completed', }), }); 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'); const activityId = await seedActivity('Drogen'); const res = await app.request('/api/admin/sessions/999999', { 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', paused_seconds: 0, notes: null, status: 'completed', }), }); expect(res.status).toBe(404); }); }); describe('admin stop/discard another user session', () => { it("stops another worker's active session and computes duration", async () => { const app = createApp(); const adminTok = await authToken(app, 'stop-admin@example.com', 'admin'); const workerTok = await authToken(app, 'stop-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(); 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.end_time).not.toBeNull(); expect(body.duration_seconds).toBeGreaterThanOrEqual(0); expect(body.paused_at).toBeNull(); }); it("discards another worker's active session", async () => { const app = createApp(); const adminTok = await authToken(app, 'discard-admin@example.com', 'admin'); const workerTok = await authToken(app, 'discard-worker@example.com'); // worker const activityId = await seedActivity('Lijmen'); 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(); const res = await app.request(`/api/admin/sessions/${started.id}/discard`, { method: 'POST', headers: bearer(adminTok), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.status).toBe('discarded'); expect(body.end_time).not.toBeNull(); }); it('409s when stopping an already-closed session', async () => { const app = createApp(); const adminTok = await authToken(app, 'stop-closed-admin@example.com', 'admin'); const workerTok = await authToken(app, 'stop-closed-worker@example.com'); 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}/discard`, { method: 'POST', headers: bearer(adminTok), }); const res = await app.request(`/api/admin/sessions/${started.id}/stop`, { method: 'POST', headers: bearer(adminTok), }); 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 expect( ( await app.request('/api/admin/sessions/1/stop', { method: 'POST', headers: bearer(workerTok), }) ).status, ).toBe(403); expect( ( await app.request('/api/admin/sessions/1/discard', { method: 'POST', headers: bearer(workerTok), }) ).status, ).toBe(403); }); });