test(api): Admin sessions — fill gaps + lifecycle test
Fills the session-part coverage gaps in src/routes/admin.ts (cross-user list, active list, manual create/edit, admin stop/discard) and adds the admin↔worker lifecycle test. New route-level tests (apps/api/test/admin.test.ts): - manual create: 400 on schema-invalid body; 400 when paused_seconds > span. - edit: 404 non-numeric id; 400 schema-invalid body; 400 unparseable start_time; 400 paused_seconds > span; 404 unknown activity. - stop: 404 non-numeric/unknown id; folds an open pause into paused_seconds. - discard: 404 non-numeric/unknown id; 409 when already closed. Lifecycle (apps/api/test/integration/admin-worker-session.test.ts): worker starts → admin stops via the admin endpoint → session lands in /api/admin/report and /api/admin/export with matching totals (uses resetDb for exact counts). admin.ts session parts now at 100% stmts/lines; every session-part branch covered. The one remaining session-part branch (row.pausedSeconds ?? 0 in the stop route) is c8-ignored — paused_seconds is NOT NULL DEFAULT 0, so the null branch is DB-impossible. The remaining uncovered branches in admin.ts are the report/export parts, owned by the Reports domain (B-A6). No behavior-changing bugs found; all session-part code was already correct.
This commit is contained in:
@@ -350,6 +350,7 @@ adminRoutes.post('/api/admin/sessions/:id/stop', async (c) => {
|
|||||||
const extraPaused = row.pausedAt
|
const extraPaused = row.pausedAt
|
||||||
? Math.round((now - new Date(row.pausedAt).getTime()) / 1000)
|
? Math.round((now - new Date(row.pausedAt).getTime()) / 1000)
|
||||||
: 0;
|
: 0;
|
||||||
|
/* c8 ignore next -- pausedSeconds is NOT NULL (schema default 0); the ?? guards a DB-impossible null */
|
||||||
const pausedSeconds = (row.pausedSeconds ?? 0) + extraPaused;
|
const pausedSeconds = (row.pausedSeconds ?? 0) + extraPaused;
|
||||||
const durationSeconds = computeDuration(new Date(row.startTime).getTime(), now, pausedSeconds);
|
const durationSeconds = computeDuration(new Date(row.startTime).getTime(), now, pausedSeconds);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { Hono } from 'hono';
|
||||||
import { createApp } from '../src/app';
|
import { createApp } from '../src/app';
|
||||||
import { authToken, bearer, createTestUser, seedActivity } from './helpers';
|
import { authToken, bearer, createTestUser, seedActivity } from './helpers';
|
||||||
import { db } from '../src/db/client';
|
import { db } from '../src/db/client';
|
||||||
import { user } from '../src/db/schema';
|
import { user, workSessions } from '../src/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
async function userIdByEmail(email: string): Promise<string> {
|
async function userIdByEmail(email: string): Promise<string> {
|
||||||
@@ -11,6 +12,29 @@ async function userIdByEmail(email: string): Promise<string> {
|
|||||||
return row.id;
|
return row.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a 1h completed manual session and return its row (used to exercise the edit path).
|
||||||
|
async function seedManualSession(
|
||||||
|
app: Hono,
|
||||||
|
adminTok: string,
|
||||||
|
targetId: string,
|
||||||
|
activityId: number,
|
||||||
|
): Promise<{ id: number }> {
|
||||||
|
return (
|
||||||
|
await app.request('/api/admin/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: targetId,
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 2,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T09:00:00.000Z',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
}
|
||||||
|
|
||||||
describe('admin session views', () => {
|
describe('admin session views', () => {
|
||||||
it('401s without a token', async () => {
|
it('401s without a token', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
@@ -182,6 +206,40 @@ describe('admin manual session create', () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('400s when the body fails schema validation', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'create-badbody-admin@example.com', 'admin');
|
||||||
|
const res = await app.request('/api/admin/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({ pair_count: 0 }), // missing required fields + invalid pair_count
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s when paused_seconds exceeds the worked span', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'create-pausedbig-admin@example.com', 'admin');
|
||||||
|
await createTestUser('create-pausedbig-target@example.com');
|
||||||
|
const targetId = await userIdByEmail('create-pausedbig-target@example.com');
|
||||||
|
const activityId = await seedActivity('Frezen');
|
||||||
|
|
||||||
|
const res = await app.request('/api/admin/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: targetId,
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 1,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T09:00:00.000Z', // 3600s span
|
||||||
|
paused_seconds: 4000, // > span
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it('403s for a worker', async () => {
|
it('403s for a worker', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const workerTok = await authToken(app, 'create-worker@example.com'); // worker
|
const workerTok = await authToken(app, 'create-worker@example.com'); // worker
|
||||||
@@ -330,6 +388,112 @@ describe('admin session edit', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('404s for a non-numeric session id', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'edit-nan-admin@example.com', 'admin');
|
||||||
|
const res = await app.request('/api/admin/sessions/not-a-number', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
activity_id: 1,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 1,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: null,
|
||||||
|
paused_seconds: 0,
|
||||||
|
notes: null,
|
||||||
|
status: 'active',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s when the body fails schema validation', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'edit-badbody-admin@example.com', 'admin');
|
||||||
|
const res = await app.request('/api/admin/sessions/1', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({ pair_count: 0 }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s when start_time is not a valid date', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'edit-badstart-admin@example.com', 'admin');
|
||||||
|
await createTestUser('edit-badstart-target@example.com');
|
||||||
|
const targetId = await userIdByEmail('edit-badstart-target@example.com');
|
||||||
|
const activityId = await seedActivity('Frezen');
|
||||||
|
const created = await seedManualSession(app, adminTok, targetId, activityId);
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/sessions/${created.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 2,
|
||||||
|
start_time: 'not-a-date',
|
||||||
|
end_time: null,
|
||||||
|
paused_seconds: 0,
|
||||||
|
notes: null,
|
||||||
|
status: 'active',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s when paused_seconds exceeds the span on edit', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'edit-pausedbig-admin@example.com', 'admin');
|
||||||
|
await createTestUser('edit-pausedbig-target@example.com');
|
||||||
|
const targetId = await userIdByEmail('edit-pausedbig-target@example.com');
|
||||||
|
const activityId = await seedActivity('Lijmen');
|
||||||
|
const created = await seedManualSession(app, adminTok, targetId, activityId);
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/sessions/${created.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
activity_id: activityId,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 2,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T09:00:00.000Z', // 3600s span
|
||||||
|
paused_seconds: 5000, // > span
|
||||||
|
notes: null,
|
||||||
|
status: 'completed',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s for an unknown activity on edit', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'edit-noact-admin@example.com', 'admin');
|
||||||
|
await createTestUser('edit-noact-target@example.com');
|
||||||
|
const targetId = await userIdByEmail('edit-noact-target@example.com');
|
||||||
|
const activityId = await seedActivity('Snijden');
|
||||||
|
const created = await seedManualSession(app, adminTok, targetId, activityId);
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/sessions/${created.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
activity_id: 999999,
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 2,
|
||||||
|
start_time: '2026-06-17T08:00:00.000Z',
|
||||||
|
end_time: '2026-06-17T09:00:00.000Z',
|
||||||
|
paused_seconds: 0,
|
||||||
|
notes: null,
|
||||||
|
status: 'completed',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
it('404s for an unknown session', async () => {
|
it('404s for an unknown session', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const adminTok = await authToken(app, 'edit-404-admin@example.com', 'admin');
|
const adminTok = await authToken(app, 'edit-404-admin@example.com', 'admin');
|
||||||
@@ -429,6 +593,106 @@ describe('admin stop/discard another user session', () => {
|
|||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('404s for a non-numeric or unknown stop id', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'stop-404-admin@example.com', 'admin');
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request('/api/admin/sessions/not-a-number/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request('/api/admin/sessions/999999/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds an open pause into paused_seconds when stopping', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'stop-paused-admin@example.com', 'admin');
|
||||||
|
const workerTok = await authToken(app, 'stop-paused-worker@example.com'); // worker
|
||||||
|
const activityId = await seedActivity('Frezen');
|
||||||
|
|
||||||
|
const started = await (
|
||||||
|
await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(workerTok),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
|
||||||
|
// Back-date the start (20s) and leave an open pause (6s ago) so the admin stop
|
||||||
|
// folds the running pause into paused_seconds and excludes it from worked time.
|
||||||
|
await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({ startTime: new Date(Date.now() - 20000), pausedAt: new Date(Date.now() - 6000) })
|
||||||
|
.where(eq(workSessions.id, started.id));
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/sessions/${started.id}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe('completed');
|
||||||
|
expect(body.paused_at).toBeNull();
|
||||||
|
expect(body.paused_seconds).toBeGreaterThanOrEqual(6); // the open pause was folded in
|
||||||
|
expect(body.duration_seconds).toBeLessThan(20); // paused time excluded from worked
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s for a non-numeric or unknown discard id', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'discard-404-admin@example.com', 'admin');
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request('/api/admin/sessions/not-a-number/discard', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request('/api/admin/sessions/999999/discard', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409s when discarding an already-closed session', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'discard-closed-admin@example.com', 'admin');
|
||||||
|
const workerTok = await authToken(app, 'discard-closed-worker@example.com'); // worker
|
||||||
|
const activityId = await seedActivity('Snijden');
|
||||||
|
|
||||||
|
const started = await (
|
||||||
|
await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(workerTok),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
await app.request(`/api/admin/sessions/${started.id}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`/api/admin/sessions/${started.id}/discard`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
it('403s for a worker hitting stop/discard', async () => {
|
it('403s for a worker hitting stop/discard', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const workerTok = await authToken(app, 'stop-worker-gate@example.com'); // worker
|
const workerTok = await authToken(app, 'stop-worker-gate@example.com'); // worker
|
||||||
|
|||||||
68
apps/api/test/integration/admin-worker-session.test.ts
Normal file
68
apps/api/test/integration/admin-worker-session.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { db } from '../../src/db/client';
|
||||||
|
import { workSessions } from '../../src/db/schema';
|
||||||
|
import { authToken, bearer, seedActivity, resetDb } from '../helpers';
|
||||||
|
|
||||||
|
const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;
|
||||||
|
|
||||||
|
describe('admin↔worker session lifecycle (worker starts → admin stops → report + export)', () => {
|
||||||
|
beforeEach(resetDb);
|
||||||
|
|
||||||
|
it('an admin-stopped worker session lands in the report and export with correct totals', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'admin-ws-admin@example.com', 'admin');
|
||||||
|
const workerTok = await authToken(app, 'admin-ws-worker@example.com'); // worker
|
||||||
|
const activityId = await seedActivity('Frezen', ['Kurk']);
|
||||||
|
|
||||||
|
// Worker starts a session.
|
||||||
|
const started = await (
|
||||||
|
await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(workerTok),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 3 }),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
|
||||||
|
// Back-date the start so the admin stop yields a deterministic-ish worked span.
|
||||||
|
await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({ startTime: new Date(Date.now() - 120000) })
|
||||||
|
.where(eq(workSessions.id, started.id));
|
||||||
|
|
||||||
|
// Admin stops it via the admin endpoint.
|
||||||
|
const stopRes = await app.request(`/api/admin/sessions/${started.id}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
});
|
||||||
|
expect(stopRes.status).toBe(200);
|
||||||
|
const stopped = await stopRes.json();
|
||||||
|
expect(stopped.status).toBe('completed');
|
||||||
|
expect(stopped.duration_seconds).toBeGreaterThanOrEqual(120);
|
||||||
|
|
||||||
|
// The session shows up in the admin report with totals matching the stored row.
|
||||||
|
const report = await (
|
||||||
|
await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) })
|
||||||
|
).json();
|
||||||
|
expect(report.totals.sessions).toBe(1);
|
||||||
|
expect(report.totals.worked_seconds).toBe(stopped.duration_seconds);
|
||||||
|
expect(report.totals.pairs).toBe(3);
|
||||||
|
expect(report.by_worker).toHaveLength(1);
|
||||||
|
expect(report.by_worker[0].user_name).toBe('admin-ws-worker');
|
||||||
|
expect(report.by_worker[0].worked_seconds).toBe(stopped.duration_seconds);
|
||||||
|
expect(report.by_activity).toHaveLength(1);
|
||||||
|
expect(report.by_activity[0].activity_name).toBe('Frezen');
|
||||||
|
expect(report.by_type).toHaveLength(1);
|
||||||
|
expect(report.by_type[0].insole_type).toBe('Kurk');
|
||||||
|
|
||||||
|
// And in the all-users export CSV (header + the one row, with the Worker column).
|
||||||
|
const csvRes = await app.request(`/api/admin/export?${WIDE}`, { headers: bearer(adminTok) });
|
||||||
|
expect(csvRes.status).toBe(200);
|
||||||
|
const lines = (await csvRes.text()).trim().split('\n');
|
||||||
|
expect(lines).toHaveLength(2);
|
||||||
|
expect(lines[0]).toContain('"Worker"');
|
||||||
|
expect(lines[1]).toContain('admin-ws-worker');
|
||||||
|
expect(lines[1]).toContain('Frezen');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -153,3 +153,36 @@ Guaranteed behaviors:
|
|||||||
header.
|
header.
|
||||||
- Lifecycle: covered by the worker-session anchor (`test/integration/worker-session-lifecycle.test.ts`),
|
- 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.
|
whose final step asserts `GET /api/export` returns the header + exactly one completed-session row.
|
||||||
|
|
||||||
|
## Admin sessions
|
||||||
|
|
||||||
|
Src covered: the session parts of `src/routes/admin.ts` (cross-user list, active list, manual
|
||||||
|
create/edit, admin stop/discard) — all session-part statements, lines, and branches covered, so
|
||||||
|
`admin.ts` is at 100% stmts/lines. The one `row.pausedSeconds ?? 0` guard in the admin stop route
|
||||||
|
is `c8 ignore`d with a reason — `paused_seconds` is `NOT NULL DEFAULT 0`, so the null branch is
|
||||||
|
DB-impossible. (The report/export parts of `admin.ts` — `parseReportQuery`, the aggregation `??`
|
||||||
|
fallbacks, and the export invalid-query branch — are owned and covered by the Reports domain.)
|
||||||
|
|
||||||
|
Guaranteed behaviors:
|
||||||
|
|
||||||
|
- Every `/api/admin/*` session route is gated: 401 without a token, 403 for a worker.
|
||||||
|
- `GET /api/admin/sessions` returns **all** users' sessions newest-first, each joined to
|
||||||
|
`activity_name`, `user_name`, and `user_email`; `GET /api/admin/sessions/active` is the same but
|
||||||
|
filtered to `active` sessions only.
|
||||||
|
- `POST /api/admin/sessions` (manual create): creates a `completed`, `source: 'manual'` session
|
||||||
|
with `duration_seconds = span − paused_seconds` and null `paused_at`; 400 on a schema-invalid
|
||||||
|
body, on `end_time < start_time`, and when `paused_seconds` exceeds the worked span; 404 for an
|
||||||
|
unknown `user_id` and for an unknown `activity_id`.
|
||||||
|
- `PUT /api/admin/sessions/:id` (edit): recomputes `duration_seconds` when `end_time` is present and
|
||||||
|
sets it null when `end_time` is null (re-activating); 404 for a non-numeric or unknown id; 400 on
|
||||||
|
a schema-invalid body, on an unparseable `start_time`, on `end_time < start_time`, and when
|
||||||
|
`paused_seconds` exceeds the span; 404 for an unknown `activity_id`.
|
||||||
|
- `POST /api/admin/sessions/:id/stop`: completes another user's active session, folds an open pause
|
||||||
|
span into `paused_seconds`, clears `paused_at`, and computes `duration_seconds` excluding paused
|
||||||
|
time; 404 for a non-numeric or unknown id; 409 when the session is already closed.
|
||||||
|
- `POST /api/admin/sessions/:id/discard`: marks another user's active session `discarded` with an
|
||||||
|
`end_time`; 404 for a non-numeric or unknown id; 409 when the session is already closed.
|
||||||
|
- Lifecycle (`test/integration/admin-worker-session.test.ts`): a worker starts a session → an admin
|
||||||
|
stops it via the admin endpoint → the completed session appears in `GET /api/admin/report`
|
||||||
|
(totals, `by_worker`, `by_activity`, `by_type` all matching the stored row) and in
|
||||||
|
`GET /api/admin/export` (header + exactly one row, with the leading Worker column).
|
||||||
|
|||||||
Reference in New Issue
Block a user