feat(api): server-authoritative pause/resume + worked-time stop + CSV paused

Add user-scoped POST /api/sessions/:id/pause and /resume endpoints,
mirroring the stop handler's ownership/lookup and 401/404/409 guards.
Pause sets paused_at (status stays active); resume folds the open span
into paused_seconds and clears paused_at.

Change stop to fold any open pause span into paused_seconds, then set
duration_seconds = max(0, round((end-start)/1000) - paused_seconds) so
saved duration is worked time, and clear paused_at.

Add a "Paused Duration" column to /api/export (after "Total Duration")
using formatDuration(paused_seconds).

Products affected: SoleLog backend (apps/api)
This commit is contained in:
Bas van Rossem
2026-06-17 20:54:42 +02:00
parent 0d82b6efbc
commit 974ecb120d
3 changed files with 240 additions and 6 deletions

View File

@@ -149,6 +149,164 @@ describe('session lifecycle', () => {
expect(body.duration_seconds).toBeNull();
});
it('401s pause/resume without a token', async () => {
const app = createApp();
const pause = await app.request('/api/sessions/1/pause', { method: 'POST' });
expect(pause.status).toBe(401);
const resume = await app.request('/api/sessions/1/resume', { method: 'POST' });
expect(resume.status).toBe(401);
});
it('404s pause/resume for a missing session', async () => {
const app = createApp();
const token = await authToken(app, 'sess-pause-missing@example.com');
const pause = await app.request('/api/sessions/999999/pause', {
method: 'POST',
headers: bearer(token),
});
expect(pause.status).toBe(404);
const resume = await app.request('/api/sessions/999999/resume', {
method: 'POST',
headers: bearer(token),
});
expect(resume.status).toBe(404);
});
it('pauses an active session and 409s a double pause', async () => {
const app = createApp();
const token = await authToken(app, 'sess-pause@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();
const pause = await app.request(`/api/sessions/${started.id}/pause`, {
method: 'POST',
headers: bearer(token),
});
expect(pause.status).toBe(200);
const paused = await pause.json();
expect(paused.status).toBe('active');
expect(paused.paused_at).not.toBeNull();
const again = await app.request(`/api/sessions/${started.id}/pause`, {
method: 'POST',
headers: bearer(token),
});
expect(again.status).toBe(409);
});
it('resumes a paused session, accumulating paused_seconds, and 409s a running resume', async () => {
const app = createApp();
const token = await authToken(app, 'sess-resume@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();
// Resuming a running session is a 409.
const runningResume = await app.request(`/api/sessions/${started.id}/resume`, {
method: 'POST',
headers: bearer(token),
});
expect(runningResume.status).toBe(409);
await app.request(`/api/sessions/${started.id}/pause`, {
method: 'POST',
headers: bearer(token),
});
// Backdate the pause start by 3s so resume accumulates a positive span.
await db
.update(workSessions)
.set({ pausedAt: new Date(Date.now() - 3000) })
.where(eq(workSessions.id, started.id));
const resume = await app.request(`/api/sessions/${started.id}/resume`, {
method: 'POST',
headers: bearer(token),
});
expect(resume.status).toBe(200);
const resumed = await resume.json();
expect(resumed.paused_at).toBeNull();
expect(resumed.status).toBe('active');
expect(resumed.paused_seconds).toBeGreaterThan(0);
});
it('stop folds an open pause span and excludes paused time from worked duration', async () => {
const app = createApp();
const token = await authToken(app, 'sess-stop-paused@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();
// The session has run for 10s wall-clock and has been paused for the last 4s
// (open span — paused_at still set at stop time).
await db
.update(workSessions)
.set({ startTime: new Date(Date.now() - 10000), pausedAt: new Date(Date.now() - 4000) })
.where(eq(workSessions.id, started.id));
const res = await app.request(`/api/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(token),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('completed');
expect(body.paused_at).toBeNull();
// Robust relationships rather than exact seconds.
expect(body.paused_seconds).toBeGreaterThan(0);
expect(body.duration_seconds).toBeGreaterThanOrEqual(0);
expect(body.duration_seconds).toBeLessThan(10);
const wall = Math.round((Date.parse(body.end_time) - Date.parse(body.start_time)) / 1000);
expect(body.duration_seconds + body.paused_seconds).toBe(wall);
});
it('stop on a never-paused session keeps paused_seconds at 0 and duration = wall-clock', async () => {
const app = createApp();
const token = await authToken(app, 'sess-stop-nopause@example.com');
const activityId = await seedActivity('Afwerken');
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 db
.update(workSessions)
.set({ startTime: new Date(Date.now() - 5000) })
.where(eq(workSessions.id, started.id));
const res = await app.request(`/api/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(token),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.paused_seconds).toBe(0);
expect(body.duration_seconds).toBe(5);
});
it("does not let user B stop user A's session", async () => {
const app = createApp();
const tokenA = await authToken(app, 'sess-ownerA@example.com');