feat(api): admin report endpoint + ReportResponse contracts
This commit is contained in:
135
apps/api/test/report.test.ts
Normal file
135
apps/api/test/report.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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 { authToken, bearer, seedActivity } 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', () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user