test(api): Reports + admin export — fill gaps + lifecycle test

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.
This commit is contained in:
Bas van Rossem
2026-07-22 13:03:04 +02:00
parent bdf65ae685
commit 31d13a8381
4 changed files with 325 additions and 6 deletions

View File

@@ -1,10 +1,10 @@
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 { user, workSessions } from '../src/db/schema';
import { eq } from 'drizzle-orm';
import { authToken, bearer, seedActivity } from './helpers';
import { authToken, bearer, seedActivity, resetDb } from './helpers';
const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;
@@ -35,6 +35,8 @@ async function completed(
}
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);
@@ -132,9 +134,114 @@ describe('GET /api/admin/report', () => {
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);
@@ -143,6 +250,18 @@ describe('GET /api/admin/export', () => {
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');
@@ -152,9 +271,8 @@ describe('GET /api/admin/export', () => {
await completed(app, a, act, 'Kurk', 90, 2);
await completed(app, b, act, 'Berk', 60, 2);
// Scope to this test's own activity so the all-users export isolates from the
// sessions other tests seed into the shared, run-scoped DB.
const res = await app.request(`/api/admin/export?${WIDE}&activity_id=${act}`, {
// 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);