test(api): Work-sessions — fill gaps + lifecycle test
Drives src/routes/sessions.ts and src/lib/work-session.ts to 100% stmts/branch/funcs/lines (branch was 84.12% / 83.33%). - sessions.test.ts: added error/edge branch tests — malformed-JSON start (400), non-numeric id on pause/resume/stop/discard (404), pause/resume of a no-longer-active session (409), discard missing (404) + already-closed (409), stop missing (404), and discard ownership scoping (user B → 404). - work-session.test.ts: mapper unit tests — null insoleType, nullish pausedSeconds → 0, null durationSeconds/notes, full-field shape with activity/user opts, and opts defaulting to undefined. - test/integration/worker-session-lifecycle.test.ts: the anchor lifecycle test (start→pause→resume→stop→history→export). No bugs found — the code was correct. The two remaining uncovered branches were the defensive `row.pausedSeconds ?? 0` guards in resume/stop; pausedSeconds is NOT NULL DEFAULT 0 in the schema, so the null branch is unreachable via any route. Annotated both with `/* c8 ignore next -- reason */` (comment only, no behavior change).
This commit is contained in:
@@ -136,6 +136,7 @@ sessionsRoutes.post('/api/sessions/:id/resume', async (c) => {
|
|||||||
if (!row.pausedAt) return c.json({ error: 'Not paused' }, 409);
|
if (!row.pausedAt) return c.json({ error: 'Not paused' }, 409);
|
||||||
|
|
||||||
const pausedSeconds =
|
const pausedSeconds =
|
||||||
|
/* c8 ignore next -- pausedSeconds is NOT NULL (default 0) in schema; ?? 0 is a defensive guard */
|
||||||
(row.pausedSeconds ?? 0) + Math.round((Date.now() - new Date(row.pausedAt).getTime()) / 1000);
|
(row.pausedSeconds ?? 0) + Math.round((Date.now() - new Date(row.pausedAt).getTime()) / 1000);
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
@@ -164,6 +165,7 @@ sessionsRoutes.post('/api/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 (default 0) in schema; ?? 0 is a defensive guard */
|
||||||
const pausedSeconds = (row.pausedSeconds ?? 0) + extraPaused;
|
const pausedSeconds = (row.pausedSeconds ?? 0) + extraPaused;
|
||||||
const endTime = new Date(now);
|
const endTime = new Date(now);
|
||||||
const wall = Math.round((now - new Date(row.startTime).getTime()) / 1000);
|
const wall = Math.round((now - new Date(row.startTime).getTime()) / 1000);
|
||||||
|
|||||||
69
apps/api/test/integration/worker-session-lifecycle.test.ts
Normal file
69
apps/api/test/integration/worker-session-lifecycle.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
describe('worker session lifecycle (start→pause→resume→stop→history→export)', () => {
|
||||||
|
beforeEach(resetDb);
|
||||||
|
|
||||||
|
it('excludes paused time, shows in history, and exports a CSV row', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'lifecycle@example.com');
|
||||||
|
const activityId = await seedActivity('Frezen', ['Kurk']);
|
||||||
|
|
||||||
|
// start
|
||||||
|
const startRes = await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 3 }),
|
||||||
|
});
|
||||||
|
expect(startRes.status).toBe(200);
|
||||||
|
const started = await startRes.json();
|
||||||
|
|
||||||
|
// pause, then backdate the pause start by 4s so resume accumulates a real span
|
||||||
|
await app.request(`/api/sessions/${started.id}/pause`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({ pausedAt: new Date(Date.now() - 4000) })
|
||||||
|
.where(eq(workSessions.id, started.id));
|
||||||
|
const resumeRes = await app.request(`/api/sessions/${started.id}/resume`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect((await resumeRes.json()).paused_seconds).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// backdate the start by 10s so worked-time is deterministic-ish, then stop
|
||||||
|
await db
|
||||||
|
.update(workSessions)
|
||||||
|
.set({ startTime: new Date(Date.now() - 10000) })
|
||||||
|
.where(eq(workSessions.id, started.id));
|
||||||
|
const stopRes = await app.request(`/api/sessions/${started.id}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
const stopped = await stopRes.json();
|
||||||
|
expect(stopped.status).toBe('completed');
|
||||||
|
expect(stopped.paused_seconds).toBeGreaterThan(0);
|
||||||
|
expect(stopped.duration_seconds).toBeLessThan(10); // paused time excluded
|
||||||
|
const wall = Math.round((Date.parse(stopped.end_time) - Date.parse(stopped.start_time)) / 1000);
|
||||||
|
expect(stopped.duration_seconds + stopped.paused_seconds).toBe(wall);
|
||||||
|
|
||||||
|
// history shows it
|
||||||
|
const hist = await (await app.request('/api/sessions', { headers: bearer(token) })).json();
|
||||||
|
expect(hist).toHaveLength(1);
|
||||||
|
expect(hist[0].id).toBe(started.id);
|
||||||
|
expect(hist[0].activity_name).toBe('Frezen');
|
||||||
|
|
||||||
|
// export contains the completed row
|
||||||
|
const csvRes = await app.request('/api/export', { headers: bearer(token) });
|
||||||
|
expect(csvRes.status).toBe(200);
|
||||||
|
const csv = await csvRes.text();
|
||||||
|
expect(csv).toContain('Frezen');
|
||||||
|
expect(csv.trim().split('\n').length).toBe(2); // header + 1 row
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -441,3 +441,146 @@ describe('session reads', () => {
|
|||||||
expect(body[0].status).toBe('active');
|
expect(body[0].status).toBe('active');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('session edge and error branches', () => {
|
||||||
|
beforeEach(resetDb);
|
||||||
|
|
||||||
|
it('400s start when the body is not valid JSON', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'sess-badjson@example.com');
|
||||||
|
|
||||||
|
const res = await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: '{ not json',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s pause/resume/stop/discard when the id is not numeric', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'sess-nan-id@example.com');
|
||||||
|
|
||||||
|
for (const action of ['pause', 'resume', 'stop', 'discard']) {
|
||||||
|
const res = await app.request(`/api/sessions/not-a-number/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409s pausing a session that is no longer active', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'sess-pause-closed@example.com');
|
||||||
|
const activityId = await seedActivity('Frezen');
|
||||||
|
|
||||||
|
const startRes = await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
});
|
||||||
|
const started = await startRes.json();
|
||||||
|
await app.request(`/api/sessions/${started.id}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`/api/sessions/${started.id}/pause`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409s resuming a session that is no longer active', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'sess-resume-closed@example.com');
|
||||||
|
const activityId = await seedActivity('Slijpen');
|
||||||
|
|
||||||
|
const startRes = await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
});
|
||||||
|
const started = await startRes.json();
|
||||||
|
await app.request(`/api/sessions/${started.id}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`/api/sessions/${started.id}/resume`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s discarding a missing session', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'sess-discard-missing@example.com');
|
||||||
|
|
||||||
|
const res = await app.request('/api/sessions/999999/discard', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409s discarding an already-closed session', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'sess-discard-closed@example.com');
|
||||||
|
const activityId = await seedActivity('Bekleden');
|
||||||
|
|
||||||
|
const startRes = await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
|
||||||
|
});
|
||||||
|
const started = await startRes.json();
|
||||||
|
await app.request(`/api/sessions/${started.id}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`/api/sessions/${started.id}/discard`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s stopping a missing session', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const token = await authToken(app, 'sess-stop-missing@example.com');
|
||||||
|
|
||||||
|
const res = await app.request('/api/sessions/999999/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(token),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let user B discard user A's session (ownership scoping → 404)", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const tokenA = await authToken(app, 'sess-discard-ownerA@example.com');
|
||||||
|
const tokenB = await authToken(app, 'sess-discard-ownerB@example.com');
|
||||||
|
const activityId = await seedActivity('Printen');
|
||||||
|
|
||||||
|
const startRes = await app.request('/api/sessions/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(tokenA),
|
||||||
|
body: JSON.stringify({ activity_id: activityId, insole_type: '3D', pair_count: 2 }),
|
||||||
|
});
|
||||||
|
const started = await startRes.json();
|
||||||
|
|
||||||
|
const res = await app.request(`/api/sessions/${started.id}/discard`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(tokenB),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
|
||||||
|
const [row] = await db.select().from(workSessions).where(eq(workSessions.id, started.id));
|
||||||
|
expect(row.status).toBe('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,3 +37,71 @@ describe('toWorkSession paused fields', () => {
|
|||||||
expect(result.paused_at).toBe(pausedAt.toISOString());
|
expect(result.paused_at).toBe(pausedAt.toISOString());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toWorkSession field mapping', () => {
|
||||||
|
it('maps a null insoleType to null', () => {
|
||||||
|
const result = toWorkSession(baseRow({ insoleType: null }));
|
||||||
|
expect(result.insole_type).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces a nullish pausedSeconds to 0 (defensive against a null column value)', () => {
|
||||||
|
const result = toWorkSession(baseRow({ pausedSeconds: null as unknown as number }));
|
||||||
|
expect(result.paused_seconds).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces a nullish durationSeconds to null and a nullish notes to null', () => {
|
||||||
|
const result = toWorkSession(baseRow({ durationSeconds: null, notes: null }));
|
||||||
|
expect(result.duration_seconds).toBeNull();
|
||||||
|
expect(result.notes).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shapes every field, threading activity/user opts through', () => {
|
||||||
|
const start = new Date('2026-06-17T08:00:00.000Z');
|
||||||
|
const end = new Date('2026-06-17T09:30:00.000Z');
|
||||||
|
const created = new Date('2026-06-17T07:59:00.000Z');
|
||||||
|
const result = toWorkSession(
|
||||||
|
baseRow({
|
||||||
|
id: 42,
|
||||||
|
userId: 'user-42',
|
||||||
|
activityId: 7,
|
||||||
|
insoleType: 'Berk',
|
||||||
|
pairCount: 4,
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
durationSeconds: 5000,
|
||||||
|
pausedSeconds: 400,
|
||||||
|
status: 'completed',
|
||||||
|
source: 'manual',
|
||||||
|
notes: 'handmatig',
|
||||||
|
createdAt: created,
|
||||||
|
}),
|
||||||
|
{ activityName: 'Frezen', userName: 'Jan', userEmail: 'jan@example.com' },
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: 42,
|
||||||
|
user_id: 'user-42',
|
||||||
|
activity_id: 7,
|
||||||
|
activity_name: 'Frezen',
|
||||||
|
user_name: 'Jan',
|
||||||
|
user_email: 'jan@example.com',
|
||||||
|
insole_type: 'Berk',
|
||||||
|
pair_count: 4,
|
||||||
|
start_time: start.toISOString(),
|
||||||
|
end_time: end.toISOString(),
|
||||||
|
duration_seconds: 5000,
|
||||||
|
paused_seconds: 400,
|
||||||
|
paused_at: null,
|
||||||
|
status: 'completed',
|
||||||
|
source: 'manual',
|
||||||
|
notes: 'handmatig',
|
||||||
|
created_at: created.toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults activity/user opts to undefined when omitted', () => {
|
||||||
|
const result = toWorkSession(baseRow());
|
||||||
|
expect(result.activity_name).toBeUndefined();
|
||||||
|
expect(result.user_name).toBeUndefined();
|
||||||
|
expect(result.user_email).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -90,3 +90,40 @@ Guaranteed behaviors:
|
|||||||
unknown id).
|
unknown id).
|
||||||
- Lifecycle: an admin creates three activities → reorders them → a worker's GET reflects the new
|
- Lifecycle: an admin creates three activities → reorders them → a worker's GET reflects the new
|
||||||
order → the admin deletes one → it is gone from the list.
|
order → the admin deletes one → it is gone from the list.
|
||||||
|
|
||||||
|
## Work-sessions
|
||||||
|
|
||||||
|
Src covered: `src/routes/sessions.ts` and `src/lib/work-session.ts` — both at
|
||||||
|
100% stmts/branch/funcs/lines. The two `row.pausedSeconds ?? 0` guards in `sessions.ts`
|
||||||
|
(resume/stop) are `c8 ignore`d with a reason — `paused_seconds` is `NOT NULL DEFAULT 0`, so the
|
||||||
|
null branch is unreachable via any route.
|
||||||
|
|
||||||
|
Guaranteed behaviors:
|
||||||
|
|
||||||
|
- `start`, `pause`, `resume`, `stop`, `discard` all require a session (401 without a token), as do
|
||||||
|
the reads `GET /api/sessions` and `GET /api/sessions/active`.
|
||||||
|
- `POST /api/sessions/start`: 200 creates an `active`, `source: 'app'` session (null `end_time` /
|
||||||
|
`duration_seconds`); 400 on an invalid body **and** on a malformed-JSON body; 404 when the
|
||||||
|
`activity_id` does not exist.
|
||||||
|
- `POST /:id/pause`: 200 sets `paused_at` on a running session; 404 for a non-numeric or unknown
|
||||||
|
id; 409 on a double pause (already paused) and 409 when the session is no longer active.
|
||||||
|
- `POST /:id/resume`: 200 clears `paused_at` and accumulates `paused_seconds`; 404 for a
|
||||||
|
non-numeric or unknown id; 409 when the session is running (not paused) and 409 when it is no
|
||||||
|
longer active.
|
||||||
|
- `POST /:id/stop`: 200 completes the session, folds an open pause span into `paused_seconds`, and
|
||||||
|
computes `duration_seconds = wall-clock − paused_seconds` (never negative); a never-paused stop
|
||||||
|
keeps `paused_seconds = 0` and `duration = wall-clock`; 404 for a non-numeric/unknown id; 409
|
||||||
|
when already closed.
|
||||||
|
- `POST /:id/discard`: 200 marks the session `discarded` with a null `duration_seconds`; 404 for a
|
||||||
|
non-numeric/unknown id; 409 when already closed.
|
||||||
|
- Ownership scoping: user B can neither stop nor discard user A's session — both return 404 and
|
||||||
|
leave A's session untouched (still `active`).
|
||||||
|
- Reads: `GET /api/sessions` returns the caller's sessions only, newest-first, joined to the
|
||||||
|
activity `name`; `GET /api/sessions/active` returns only that user's `active` sessions.
|
||||||
|
- `toWorkSession` maps every field to the API contract (incl. `paused_at`, `paused_seconds`,
|
||||||
|
`notes`, `created_at`), threads `activity_name` / `user_name` / `user_email` opts through
|
||||||
|
(undefined when omitted), maps a null `insoleType` / `durationSeconds` / `notes` to null, and
|
||||||
|
coalesces a nullish `paused_seconds` to 0.
|
||||||
|
- Lifecycle: start → pause → (backdate) resume accumulates paused time → (backdate) stop excludes
|
||||||
|
paused time from worked duration → the completed session appears in history with its activity
|
||||||
|
name → `GET /api/export` returns a CSV with exactly the header + one data row.
|
||||||
|
|||||||
Reference in New Issue
Block a user