diff --git a/apps/api/test/csv.test.ts b/apps/api/test/csv.test.ts index ab2967a..03c32eb 100644 --- a/apps/api/test/csv.test.ts +++ b/apps/api/test/csv.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { buildSessionsCsv, type SessionCsvRow } from '../src/lib/csv'; +import { buildSessionsCsv, quote, formatDuration, type SessionCsvRow } from '../src/lib/csv'; const row: SessionCsvRow = { id: 7, @@ -31,4 +31,52 @@ describe('buildSessionsCsv', () => { ); expect(lines[1].startsWith('"Jan"')).toBe(true); }); + + it('applies legacy fallbacks for null fields and blanks a missing end time', () => { + // Every nullable field null (+ pairCount null via cast, since the type is non-null + // but the code still carries a legacy `?? 2` default). includeWorker exercises the + // userName fallback too. Covers each `??` fallback branch and the null-end-time branch. + const emptyRow = { + id: 1, + activityName: null, + userName: null, + insoleType: null, + pairCount: null, + startTime: new Date('2026-06-17T08:00:00Z'), + endTime: null, + durationSeconds: null, + pausedSeconds: null, + } as unknown as SessionCsvRow; + + const cells = buildSessionsCsv([emptyRow], { includeWorker: true }).split('\n')[1].split(','); + expect(cells[0]).toBe('""'); // Worker → '' + expect(cells[1]).toBe('"1"'); // ID + expect(cells[2]).toBe('""'); // Task → '' + expect(cells[3]).toBe('"Kurk"'); // Insole Type → legacy default + expect(cells[4]).toBe('"2"'); // No. of Insoles → legacy default + expect(cells[6]).toBe('"00:00:00"'); // Total Duration → 0 + expect(cells[7]).toBe('"00:00:00"'); // Paused Duration → 0 + expect(cells[9]).toBe('""'); // End Time → '' (null end) + }); + + it('escapes commas, quotes and newlines inside a field', () => { + const tricky: SessionCsvRow = { ...row, activityName: 'Frezen, "spoed"\n2e regel' }; + const csv = buildSessionsCsv([tricky]); + // Comma stays inside the quoted cell, embedded quotes are doubled, newline is preserved. + expect(csv).toContain('"Frezen, ""spoed""\n2e regel"'); + }); +}); + +describe('csv helpers (edges)', () => { + it('quote wraps and escapes commas, quotes and newlines', () => { + expect(quote('a,b')).toBe('"a,b"'); + expect(quote('a"b')).toBe('"a""b"'); + expect(quote('a\nb')).toBe('"a\nb"'); + }); + + it('formatDuration renders zero and lets hours exceed 99', () => { + expect(formatDuration(0)).toBe('00:00:00'); + expect(formatDuration(3661)).toBe('01:01:01'); + expect(formatDuration(360000)).toBe('100:00:00'); + }); }); diff --git a/apps/api/test/export.test.ts b/apps/api/test/export.test.ts index 8e3f042..5728fb1 100644 --- a/apps/api/test/export.test.ts +++ b/apps/api/test/export.test.ts @@ -1,11 +1,11 @@ -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 { eq } from 'drizzle-orm'; import { quote, formatDuration } from '../src/lib/csv'; -import { authToken, bearer, seedActivity } from './helpers'; +import { authToken, bearer, seedActivity, resetDb } from './helpers'; // Start a session, backdate its start_time by `durationSeconds`, then stop it, // producing a completed session with an exact duration. @@ -31,6 +31,8 @@ async function completedSession( } describe('csv export', () => { + beforeEach(resetDb); + it('401s without a token', async () => { const app = createApp(); const res = await app.request('/api/export'); @@ -118,6 +120,32 @@ describe('csv export', () => { const lines = (await res.text()).split('\n'); expect(lines).toHaveLength(2); // header + 1 completed row }); + + it('orders rows by start time ascending', async () => { + const app = createApp(); + const token = await authToken(app, 'export-order@example.com'); + const frezenId = await seedActivity('Frezen'); + const slijpenId = await seedActivity('Slijpen'); + + // Two completed sessions; make Frezen clearly the older start. + const older = await completedSession(app, token, frezenId, 'Kurk', 30); + const newer = await completedSession(app, token, slijpenId, 'Berk', 30); + await db + .update(workSessions) + .set({ startTime: new Date(Date.now() - 60000) }) + .where(eq(workSessions.id, older)); + await db + .update(workSessions) + .set({ startTime: new Date(Date.now() - 10000) }) + .where(eq(workSessions.id, newer)); + + const lines = ( + await (await app.request('/api/export', { headers: bearer(token) })).text() + ).split('\n'); + expect(lines).toHaveLength(3); // header + 2 rows + expect(lines[1]).toContain('"Frezen"'); // oldest start first + expect(lines[2]).toContain('"Slijpen"'); + }); }); describe('csv helpers', () => { diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index f8a6482..0919dc6 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -127,3 +127,29 @@ Guaranteed behaviors: - Lifecycle: start → pause → (backdate) resume accumulates paused time → (backdate) stop excludes paused time from worked duration → the completed session appears in history with its activity name → `GET /api/export` returns a CSV with exactly the header + one data row. + +## Worker export/CSV + +Src covered: `src/lib/csv.ts` — 100% stmts/branch/funcs/lines; the `/api/export` route in +`src/routes/sessions.ts` — 100%. No `c8 ignore`s needed (every fallback branch is exercised). + +Guaranteed behaviors: + +- `buildSessionsCsv(rows)` (worker self-export, `includeWorker` defaults to false) emits the exact + legacy header `ID,Task,Insole Type,No. of Insoles,Date,Total Duration,Paused Duration,Start + Time,End Time` (each cell quoted) with no Worker column. +- `buildSessionsCsv(rows, { includeWorker: true })` prepends a `Worker` column carrying `userName`. +- Every field is quoted; `quote` escapes embedded double quotes (doubled) and keeps commas and + newlines inside the quoted cell — a comma/quote/newline in an activity name never breaks the + column or row structure. +- Legacy fallbacks: a null `activityName`/`userName` renders `''`, null `insoleType` renders + `Kurk`, null `pairCount` renders `2`, null `durationSeconds`/`pausedSeconds` render `00:00:00`, + and a null `endTime` renders an empty End Time cell. +- `formatDuration` renders zero-padded `HH:MM:SS`, handles zero (`00:00:00`), and lets hours exceed + 99 (`360000 → 100:00:00`). +- `GET /api/export`: 401 without a token; includes only the caller's `completed` sessions + (active/discarded and other users' rows excluded); rows ordered by start time ascending; sets the + `text/csv` content-type and the `attachment; filename="insole-production-report.csv"` disposition + header. +- Lifecycle: covered by the worker-session anchor (`test/integration/worker-session-lifecycle.test.ts`), + whose final step asserts `GET /api/export` returns the header + exactly one completed-session row.