feat(api): user-scoped CSV export matching legacy format
This commit is contained in:
144
apps/api/test/export.test.ts
Normal file
144
apps/api/test/export.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
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';
|
||||
|
||||
const json = { 'content-type': 'application/json' };
|
||||
|
||||
// Sign up + sign in a user, returning the bearer token.
|
||||
async function authToken(app: Hono, email: string): Promise<string> {
|
||||
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<string, string> {
|
||||
return { authorization: `Bearer ${token}`, 'content-type': 'application/json' };
|
||||
}
|
||||
|
||||
async function createActivity(app: Hono, token: string, name: string): Promise<number> {
|
||||
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<number> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user