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'; import { quote, formatDuration } from '../src/lib/csv'; import { authToken, bearer } from './helpers'; 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; } // Start a session, backdate its start_time by `durationSeconds`, then stop it, // producing a completed session with an exact duration. async function completedSession( app: Hono, token: string, activityId: number, insoleType: string, durationSeconds: number ): 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: 2 }), }); 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('csv export', () => { it('401s without a token', async () => { const app = createApp(); const res = await app.request('/api/export'); expect(res.status).toBe(401); }); it('exports completed sessions as CSV with the legacy header', async () => { const app = createApp(); const token = await authToken(app, 'export-basic@example.com'); const activityId = await createActivity(app, token, 'Frezen'); await completedSession(app, token, activityId, 'Kurk', 90); const res = await app.request('/api/export', { headers: bearer(token) }); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('text/csv'); expect(res.headers.get('content-disposition')).toBe( 'attachment; filename="insole-production-report.csv"' ); const text = await res.text(); const lines = text.split('\n'); expect(lines[0]).toBe( '"ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Start Time","End Time"' ); expect(lines).toHaveLength(2); expect(lines[1]).toContain('"Frezen"'); expect(lines[1]).toContain('"Kurk"'); expect(lines[1]).toContain('"00:01:30"'); }); it('excludes active and discarded sessions and scopes to the user', async () => { const app = createApp(); const tokenA = await authToken(app, 'export-scopeA@example.com'); const tokenB = await authToken(app, 'export-scopeB@example.com'); const activityId = await createActivity(app, tokenA, 'Slijpen'); // User A: one completed session. await completedSession(app, tokenA, activityId, 'Kurk', 30); // User A: an active session (left running). await app.request('/api/sessions/start', { method: 'POST', headers: bearer(tokenA), body: JSON.stringify({ activity_id: activityId, insole_type: 'Berk', pair_count: 2 }), }); // User A: a discarded session. const discardRes = await app.request('/api/sessions/start', { method: 'POST', headers: bearer(tokenA), body: JSON.stringify({ activity_id: activityId, insole_type: '3D', pair_count: 2 }), }); const toDiscard = await discardRes.json(); await app.request(`/api/sessions/${toDiscard.id}/discard`, { method: 'POST', headers: bearer(tokenA), }); // User B: a completed session that must not appear in A's export. await completedSession(app, tokenB, activityId, 'Kurk', 45); const res = await app.request('/api/export', { headers: bearer(tokenA) }); expect(res.status).toBe(200); const lines = (await res.text()).split('\n'); expect(lines).toHaveLength(2); // header + 1 completed row }); }); describe('csv helpers', () => { it('quote escapes embedded double quotes', () => { expect(quote('a"b')).toBe('"a""b"'); }); it('formatDuration formats seconds as HH:MM:SS', () => { expect(formatDuration(3661)).toBe('01:01:01'); expect(formatDuration(0)).toBe('00:00:00'); }); });