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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user