Drives the report/export parts of src/routes/admin.ts to 100% stmts/branch/funcs/lines. Adds one lifecycle test plus gap tests for the previously-uncovered branches; no production behavior changed. Tests added (apps/api/test): - report.test.ts: invalid insole_type / non-numeric activity_id → 400; activity filter narrows; null insole_type bucketed "Onbekend"; null duration counts as 0 worked; export 400 on missing/inverted range. Migrated the report + export describes to resetDb(beforeEach) so exact totals are order-independent (removed the activity_id isolation workaround in the all-users export test). - integration/report-aggregation.test.ts: lifecycle — 2 workers × 2 activities × 2 types → exact totals + every by_worker/by_activity/ by_type row (values and worked-desc order) → user_id filter narrows. Defensive annotations in admin.ts report loop (behavior unchanged): - pausedSeconds/pairCount ?? 0 — NOT NULL columns, DB-impossible nulls. - user_name/activity_name ?? 'Onbekend' — unreachable; userId FK cascades on delete and activityId FK is enforced (libsql enforces FKs), so every session always joins to a real user and activity. No bugs found; the code was already correct.
309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import type { Hono } from 'hono';
|
|
import { createApp } from '../src/app';
|
|
import { db } from '../src/db/client';
|
|
import { user, workSessions } from '../src/db/schema';
|
|
import { eq } from 'drizzle-orm';
|
|
import { authToken, bearer, seedActivity, resetDb } from './helpers';
|
|
|
|
const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;
|
|
|
|
async function completed(
|
|
app: Hono,
|
|
token: string,
|
|
activityId: number,
|
|
insoleType: string,
|
|
durationSeconds: number,
|
|
pairCount = 2,
|
|
): 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: pairCount,
|
|
}),
|
|
});
|
|
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('GET /api/admin/report', () => {
|
|
beforeEach(resetDb);
|
|
|
|
it('401s without a token and 403s for a worker', async () => {
|
|
const app = createApp();
|
|
expect((await app.request(`/api/admin/report?${WIDE}`)).status).toBe(401);
|
|
const workerTok = await authToken(app, 'report-worker@example.com');
|
|
const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(workerTok) });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('400s on a missing or inverted range', async () => {
|
|
const app = createApp();
|
|
const adminTok = await authToken(app, 'report-badrange@example.com', 'admin');
|
|
expect((await app.request('/api/admin/report', { headers: bearer(adminTok) })).status).toBe(
|
|
400,
|
|
);
|
|
const inverted = `from=${new Date('2026-02-01').toISOString()}&to=${new Date('2026-01-01').toISOString()}`;
|
|
expect(
|
|
(await app.request(`/api/admin/report?${inverted}`, { headers: bearer(adminTok) })).status,
|
|
).toBe(400);
|
|
});
|
|
|
|
it('aggregates totals that equal the sum of each breakdown', async () => {
|
|
const app = createApp();
|
|
const adminTok = await authToken(app, 'report-agg-admin@example.com', 'admin');
|
|
const workerTok = await authToken(app, 'report-agg-worker@example.com'); // 'report-agg-worker'
|
|
const frezen = await seedActivity('Frezen');
|
|
const lijmen = await seedActivity('Lijmen');
|
|
await completed(app, workerTok, frezen, 'Kurk', 100, 2);
|
|
await completed(app, workerTok, lijmen, 'Berk', 50, 3);
|
|
|
|
const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
|
|
expect(body.totals.worked_seconds).toBe(150);
|
|
expect(body.totals.pairs).toBe(5);
|
|
expect(body.totals.sessions).toBe(2);
|
|
|
|
const sum = (rows: { worked_seconds: number; pairs: number; sessions: number }[]) =>
|
|
rows.reduce(
|
|
(a, r) => ({
|
|
worked_seconds: a.worked_seconds + r.worked_seconds,
|
|
pairs: a.pairs + r.pairs,
|
|
sessions: a.sessions + r.sessions,
|
|
}),
|
|
{ worked_seconds: 0, pairs: 0, sessions: 0 },
|
|
);
|
|
expect(sum(body.by_worker)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
|
|
expect(sum(body.by_activity)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
|
|
expect(sum(body.by_type)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
|
|
expect(body.by_activity).toHaveLength(2);
|
|
expect(body.by_type.map((t: { insole_type: string }) => t.insole_type).sort()).toEqual([
|
|
'Berk',
|
|
'Kurk',
|
|
]);
|
|
});
|
|
|
|
it('excludes active/discarded and respects the user and type filters', async () => {
|
|
const app = createApp();
|
|
const adminTok = await authToken(app, 'report-filter-admin@example.com', 'admin');
|
|
const a = await authToken(app, 'report-filter-a@example.com');
|
|
const b = await authToken(app, 'report-filter-b@example.com');
|
|
const act = await seedActivity('Slijpen');
|
|
await completed(app, a, act, 'Kurk', 30, 2); // counts for A/Kurk
|
|
await completed(app, b, act, 'Berk', 40, 2); // counts for B/Berk
|
|
// A: an active session (no duration) — must be excluded.
|
|
await app.request('/api/sessions/start', {
|
|
method: 'POST',
|
|
headers: bearer(a),
|
|
body: JSON.stringify({ activity_id: act, insole_type: 'Kurk', pair_count: 2 }),
|
|
});
|
|
|
|
// Resolve A's user id via the roster.
|
|
const roster = await (
|
|
await app.request('/api/admin/users', { headers: bearer(adminTok) })
|
|
).json();
|
|
const userA = roster.find((u: { email: string }) => u.email === 'report-filter-a@example.com');
|
|
|
|
const res = await app.request(
|
|
`/api/admin/report?${WIDE}&user_id=${userA.id}&insole_type=Kurk`,
|
|
{ headers: bearer(adminTok) },
|
|
);
|
|
const body = await res.json();
|
|
expect(body.totals.sessions).toBe(1); // only A's completed Kurk session
|
|
expect(body.totals.worked_seconds).toBe(30);
|
|
});
|
|
|
|
it('returns zeros and empty arrays for an empty range', async () => {
|
|
const app = createApp();
|
|
const adminTok = await authToken(app, 'report-empty-admin@example.com', 'admin');
|
|
const empty = `from=${new Date('1990-01-01').toISOString()}&to=${new Date('1990-02-01').toISOString()}`;
|
|
const res = await app.request(`/api/admin/report?${empty}`, { headers: bearer(adminTok) });
|
|
const body = await res.json();
|
|
expect(body.totals).toEqual({ worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 });
|
|
expect(body.by_worker).toEqual([]);
|
|
expect(body.by_activity).toEqual([]);
|
|
expect(body.by_type).toEqual([]);
|
|
});
|
|
|
|
it('400s on an invalid insole_type or a non-numeric activity_id', async () => {
|
|
const app = createApp();
|
|
const adminTok = await authToken(app, 'report-badfilter@example.com', 'admin');
|
|
expect(
|
|
(
|
|
await app.request(`/api/admin/report?${WIDE}&insole_type=Plastic`, {
|
|
headers: bearer(adminTok),
|
|
})
|
|
).status,
|
|
).toBe(400);
|
|
expect(
|
|
(
|
|
await app.request(`/api/admin/report?${WIDE}&activity_id=abc`, {
|
|
headers: bearer(adminTok),
|
|
})
|
|
).status,
|
|
).toBe(400);
|
|
});
|
|
|
|
it('narrows by the activity filter', async () => {
|
|
const app = createApp();
|
|
const adminTok = await authToken(app, 'report-actfilter-admin@example.com', 'admin');
|
|
const worker = await authToken(app, 'report-actfilter-worker@example.com');
|
|
const frezen = await seedActivity('Frezen');
|
|
const lijmen = await seedActivity('Lijmen');
|
|
await completed(app, worker, frezen, 'Kurk', 100, 2);
|
|
await completed(app, worker, lijmen, 'Berk', 40, 2);
|
|
|
|
const body = await (
|
|
await app.request(`/api/admin/report?${WIDE}&activity_id=${frezen}`, {
|
|
headers: bearer(adminTok),
|
|
})
|
|
).json();
|
|
expect(body.totals.sessions).toBe(1);
|
|
expect(body.totals.worked_seconds).toBe(100);
|
|
expect(body.by_activity).toHaveLength(1);
|
|
expect(body.by_activity[0].activity_id).toBe(frezen);
|
|
});
|
|
|
|
it('buckets a session with a null insole_type under "Onbekend"', async () => {
|
|
const app = createApp();
|
|
const adminTok = await authToken(app, 'report-nulltype-admin@example.com', 'admin');
|
|
await authToken(app, 'report-nulltype-worker@example.com');
|
|
const [w] = await db
|
|
.select({ id: user.id })
|
|
.from(user)
|
|
.where(eq(user.email, 'report-nulltype-worker@example.com'));
|
|
const act = await seedActivity('Frezen');
|
|
await db.insert(workSessions).values({
|
|
userId: w.id,
|
|
activityId: act,
|
|
insoleType: null,
|
|
pairCount: 2,
|
|
startTime: new Date('2026-03-01T08:00:00.000Z'),
|
|
endTime: new Date('2026-03-01T08:05:00.000Z'),
|
|
durationSeconds: 300,
|
|
pausedSeconds: 0,
|
|
status: 'completed',
|
|
source: 'manual',
|
|
});
|
|
|
|
const body = await (
|
|
await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) })
|
|
).json();
|
|
// Only the type bucket falls back; the worker/activity are real.
|
|
expect(body.by_type).toHaveLength(1);
|
|
expect(body.by_type[0].insole_type).toBe('Onbekend');
|
|
expect(body.by_type[0].worked_seconds).toBe(300);
|
|
expect(body.by_worker[0].user_name).toBe('report-nulltype-worker');
|
|
expect(body.by_activity[0].activity_name).toBe('Frezen');
|
|
});
|
|
|
|
it('counts a completed session with a null duration as 0 worked seconds', async () => {
|
|
const app = createApp();
|
|
const adminTok = await authToken(app, 'report-nulldur-admin@example.com', 'admin');
|
|
await authToken(app, 'report-nulldur-worker@example.com');
|
|
const [w] = await db
|
|
.select({ id: user.id })
|
|
.from(user)
|
|
.where(eq(user.email, 'report-nulldur-worker@example.com'));
|
|
const act = await seedActivity('Frezen');
|
|
// A completed row whose duration was never computed (e.g. admin edit with no end time).
|
|
await db.insert(workSessions).values({
|
|
userId: w.id,
|
|
activityId: act,
|
|
insoleType: 'Kurk',
|
|
pairCount: 2,
|
|
startTime: new Date('2026-03-01T08:00:00.000Z'),
|
|
endTime: null,
|
|
durationSeconds: null,
|
|
pausedSeconds: 0,
|
|
status: 'completed',
|
|
source: 'manual',
|
|
});
|
|
|
|
const body = await (
|
|
await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) })
|
|
).json();
|
|
expect(body.totals.sessions).toBe(1);
|
|
expect(body.totals.worked_seconds).toBe(0); // null duration counts as 0
|
|
expect(body.by_worker[0].worked_seconds).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/admin/export', () => {
|
|
beforeEach(resetDb);
|
|
|
|
it('401s without a token and 403s for a worker', async () => {
|
|
const app = createApp();
|
|
expect((await app.request(`/api/admin/export?${WIDE}`)).status).toBe(401);
|
|
const workerTok = await authToken(app, 'export-admin-worker@example.com');
|
|
const res = await app.request(`/api/admin/export?${WIDE}`, { headers: bearer(workerTok) });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('400s on a missing or inverted range', async () => {
|
|
const app = createApp();
|
|
const adminTok = await authToken(app, 'export-badquery-admin@example.com', 'admin');
|
|
expect((await app.request('/api/admin/export', { headers: bearer(adminTok) })).status).toBe(
|
|
400,
|
|
);
|
|
const inverted = `from=${new Date('2026-02-01').toISOString()}&to=${new Date('2026-01-01').toISOString()}`;
|
|
expect(
|
|
(await app.request(`/api/admin/export?${inverted}`, { headers: bearer(adminTok) })).status,
|
|
).toBe(400);
|
|
});
|
|
|
|
it('exports all workers with a leading Worker column, range in the filename', async () => {
|
|
const app = createApp();
|
|
const adminTok = await authToken(app, 'export-admin@example.com', 'admin');
|
|
const a = await authToken(app, 'export-allusers-a@example.com');
|
|
const b = await authToken(app, 'export-allusers-b@example.com');
|
|
const act = await seedActivity('Frezen');
|
|
await completed(app, a, act, 'Kurk', 90, 2);
|
|
await completed(app, b, act, 'Berk', 60, 2);
|
|
|
|
// resetDb isolates this test, so the unfiltered export must contain exactly both workers.
|
|
const res = await app.request(`/api/admin/export?${WIDE}`, {
|
|
headers: bearer(adminTok),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toContain('text/csv');
|
|
expect(res.headers.get('content-disposition')).toMatch(
|
|
/attachment; filename="solelog-report_\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}\.csv"/,
|
|
);
|
|
|
|
const lines = (await res.text()).split('\n');
|
|
expect(lines[0]).toBe(
|
|
'"Worker","ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
|
|
);
|
|
expect(lines).toHaveLength(3); // header + 2 workers' sessions
|
|
expect(lines.some((l) => l.includes('export-allusers-a'))).toBe(true);
|
|
expect(lines.some((l) => l.includes('export-allusers-b'))).toBe(true);
|
|
});
|
|
|
|
it('respects the type filter', async () => {
|
|
const app = createApp();
|
|
const adminTok = await authToken(app, 'export-typefilter-admin@example.com', 'admin');
|
|
const a = await authToken(app, 'export-typefilter-a@example.com');
|
|
const act = await seedActivity('Lijmen');
|
|
await completed(app, a, act, 'Kurk', 30, 2);
|
|
await completed(app, a, act, 'Berk', 40, 2);
|
|
|
|
const res = await app.request(`/api/admin/export?${WIDE}&activity_id=${act}&insole_type=Berk`, {
|
|
headers: bearer(adminTok),
|
|
});
|
|
const lines = (await res.text()).split('\n');
|
|
expect(lines).toHaveLength(2); // header + only the Berk row
|
|
expect(lines[1]).toContain('"Berk"');
|
|
});
|
|
});
|