import { describe, it, expect } 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 { eq } from 'drizzle-orm'; const json = { 'content-type': 'application/json' }; // Sign up + sign in a user, returning the bearer token. async function authToken(app: Hono, email: string): Promise { const password = 'sterk-wachtwoord-123'; await app.request('/api/auth/sign-up/email', { method: 'POST', headers: json, body: JSON.stringify({ email, password, name: email.split('@')[0] }), }); const signin = await app.request('/api/auth/sign-in/email', { method: 'POST', headers: json, body: JSON.stringify({ email, password }), }); const token = signin.headers.get('set-auth-token'); if (!token) throw new Error('no token'); return token; } function bearer(token: string): Record { return { authorization: `Bearer ${token}`, 'content-type': 'application/json' }; } // Create an activity via the API and return its id. async function createActivity(app: Hono, token: string, name: string): Promise { const res = await app.request('/api/activities', { method: 'POST', headers: bearer(token), body: JSON.stringify({ name, insole_types: ['Kurk', 'Berk', '3D'] }), }); const body = await res.json(); return body.id as number; } describe('session lifecycle', () => { it('401s start/stop/discard without a token', async () => { const app = createApp(); const start = await app.request('/api/sessions/start', { method: 'POST', headers: json, body: JSON.stringify({ activity_id: 1, insole_type: 'Kurk', pair_count: 2 }), }); expect(start.status).toBe(401); const stop = await app.request('/api/sessions/1/stop', { method: 'POST' }); expect(stop.status).toBe(401); const discard = await app.request('/api/sessions/1/discard', { method: 'POST' }); expect(discard.status).toBe(401); }); it('starts an active session', async () => { const app = createApp(); const token = await authToken(app, 'sess-start@example.com'); const activityId = await createActivity(app, token, 'Frezen'); const res = await app.request('/api/sessions/start', { method: 'POST', headers: bearer(token), body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }), }); expect(res.status).toBe(200); const body = await res.json(); expect(typeof body.id).toBe('number'); expect(body.activity_id).toBe(activityId); expect(body.insole_type).toBe('Kurk'); expect(body.pair_count).toBe(2); expect(body.status).toBe('active'); expect(body.source).toBe('app'); expect(body.end_time).toBeNull(); expect(body.duration_seconds).toBeNull(); expect(typeof body.start_time).toBe('string'); expect(typeof body.user_id).toBe('string'); }); it('400s start with a bad body', async () => { const app = createApp(); const token = await authToken(app, 'sess-badbody@example.com'); const res = await app.request('/api/sessions/start', { method: 'POST', headers: bearer(token), body: JSON.stringify({}), }); expect(res.status).toBe(400); }); it('404s start for a missing activity', async () => { const app = createApp(); const token = await authToken(app, 'sess-missingact@example.com'); const res = await app.request('/api/sessions/start', { method: 'POST', headers: bearer(token), body: JSON.stringify({ activity_id: 999999, insole_type: 'Kurk', pair_count: 2 }), }); expect(res.status).toBe(404); }); it('completes a session and computes duration', async () => { const app = createApp(); const token = await authToken(app, 'sess-complete@example.com'); const activityId = await createActivity(app, token, 'Slijpen'); const startRes = await app.request('/api/sessions/start', { method: 'POST', headers: bearer(token), body: JSON.stringify({ activity_id: activityId, insole_type: 'Berk', pair_count: 2 }), }); const started = await startRes.json(); // Make the duration deterministic: backdate the start time by exactly 5s. await db .update(workSessions) .set({ startTime: new Date(Date.now() - 5000) }) .where(eq(workSessions.id, started.id)); const res = await app.request(`/api/sessions/${started.id}/stop`, { method: 'POST', headers: bearer(token), }); 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).toBe(5); }); it('409s stopping an already-completed session', async () => { const app = createApp(); const token = await authToken(app, 'sess-doublestop@example.com'); const activityId = await createActivity(app, token, '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(); const first = await app.request(`/api/sessions/${started.id}/stop`, { method: 'POST', headers: bearer(token), }); expect(first.status).toBe(200); const second = await app.request(`/api/sessions/${started.id}/stop`, { method: 'POST', headers: bearer(token), }); expect(second.status).toBe(409); }); it('discards an active session', async () => { const app = createApp(); const token = await authToken(app, 'sess-discard@example.com'); const activityId = await createActivity(app, token, 'Afwerken'); 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(); const res = await app.request(`/api/sessions/${started.id}/discard`, { method: 'POST', headers: bearer(token), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.status).toBe('discarded'); expect(body.duration_seconds).toBeNull(); }); it("does not let user B stop user A's session", async () => { const app = createApp(); const tokenA = await authToken(app, 'sess-ownerA@example.com'); const tokenB = await authToken(app, 'sess-ownerB@example.com'); const activityId = await createActivity(app, tokenA, '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}/stop`, { method: 'POST', headers: bearer(tokenB), }); expect(res.status).toBe(404); // Verify via db the session is still active. const [row] = await db.select().from(workSessions).where(eq(workSessions.id, started.id)); expect(row.status).toBe('active'); expect(row.endTime).toBeNull(); }); }); describe('session reads', () => { it('401s GET /api/sessions and /api/sessions/active without a token', async () => { const app = createApp(); const history = await app.request('/api/sessions'); expect(history.status).toBe(401); const active = await app.request('/api/sessions/active'); expect(active.status).toBe(401); }); it("returns the user's sessions joined with activity name, newest first", async () => { const app = createApp(); const token = await authToken(app, 'reads-history@example.com'); const frezenId = await createActivity(app, token, 'Frezen'); const slijpenId = await createActivity(app, token, 'Slijpen'); const firstRes = await app.request('/api/sessions/start', { method: 'POST', headers: bearer(token), body: JSON.stringify({ activity_id: frezenId, insole_type: 'Kurk', pair_count: 2 }), }); const first = await firstRes.json(); const secondRes = await app.request('/api/sessions/start', { method: 'POST', headers: bearer(token), body: JSON.stringify({ activity_id: slijpenId, insole_type: 'Berk', pair_count: 2 }), }); const second = await secondRes.json(); // Control ordering: make the Slijpen session clearly newer. await db .update(workSessions) .set({ startTime: new Date(Date.now() - 10000) }) .where(eq(workSessions.id, first.id)); await db .update(workSessions) .set({ startTime: new Date(Date.now() - 1000) }) .where(eq(workSessions.id, second.id)); const res = await app.request('/api/sessions', { headers: bearer(token) }); expect(res.status).toBe(200); const body = await res.json(); expect(body).toHaveLength(2); expect(new Date(body[0].start_time).getTime()).toBeGreaterThan( new Date(body[1].start_time).getTime() ); expect(body[0].activity_name).toBe('Slijpen'); expect(body[1].activity_name).toBe('Frezen'); }); it('scopes history to the requesting user', async () => { const app = createApp(); const tokenA = await authToken(app, 'reads-scopeA@example.com'); const tokenB = await authToken(app, 'reads-scopeB@example.com'); const activityId = await createActivity(app, tokenA, 'Bekleden'); const startRes = await app.request('/api/sessions/start', { method: 'POST', headers: bearer(tokenA), body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }), }); const aSession = await startRes.json(); const res = await app.request('/api/sessions', { headers: bearer(tokenB) }); expect(res.status).toBe(200); const body = await res.json(); const ids = body.map((s: { id: number }) => s.id); expect(ids).not.toContain(aSession.id); }); it('returns only active sessions from /api/sessions/active', async () => { const app = createApp(); const token = await authToken(app, 'reads-active@example.com'); const activityId = await createActivity(app, token, 'Afwerken'); // One session stays active. const activeRes = await app.request('/api/sessions/start', { method: 'POST', headers: bearer(token), body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }), }); const activeStarted = await activeRes.json(); // Another is started then stopped (completed). const stoppedRes = await app.request('/api/sessions/start', { method: 'POST', headers: bearer(token), body: JSON.stringify({ activity_id: activityId, insole_type: 'Berk', pair_count: 2 }), }); const stoppedStarted = await stoppedRes.json(); await app.request(`/api/sessions/${stoppedStarted.id}/stop`, { method: 'POST', headers: bearer(token), }); const res = await app.request('/api/sessions/active', { headers: bearer(token) }); expect(res.status).toBe(200); const body = await res.json(); expect(body).toHaveLength(1); expect(body[0].id).toBe(activeStarted.id); expect(body[0].status).toBe('active'); }); });