test(api): Worker export/CSV — fill gaps + lifecycle test

Drove src/lib/csv.ts to 100% stmts/branch/funcs/lines (was 97.95%/55.55%):
- added a null-fallback row test covering every ?? default (activityName,
  userName, insoleType→Kurk, pairCount→2, duration/paused→0) and the null
  end-time branch (line 65).
- added CSV-escaping edge tests: commas, embedded quotes, and newlines inside
  a field stay inside the quoted cell.
- added formatDuration edge (hours >99 → 100:00:00).
- added a /api/export ordering-by-start-time behavior test and adopted
  resetDb per-test isolation in the export suite.

No bugs found; CSV code behaves correctly. sessions.ts /api/export route
already at 100%. Full api suite green (150 tests).
This commit is contained in:
Bas van Rossem
2026-07-22 12:24:04 +02:00
parent 9ecbf952d3
commit b72076ee15
3 changed files with 105 additions and 3 deletions

View File

@@ -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');
});
});

View File

@@ -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', () => {