Files
solelog/apps/api/test/sessions.test.ts

282 lines
9.9 KiB
TypeScript

import { describe, it, expect } from 'vitest';
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 json = { 'content-type': 'application/json' };
describe('session lifecycle', () => {
it('401s start/stop/discard without a token', async () => {
const app = createApp();
const start = await app.request('/api/sessions/start', {
method: 'POST',
headers: json,
body: JSON.stringify({ activity_id: 1, insole_type: 'Kurk', pair_count: 2 }),
});
expect(start.status).toBe(401);
const stop = await app.request('/api/sessions/1/stop', { method: 'POST' });
expect(stop.status).toBe(401);
const discard = await app.request('/api/sessions/1/discard', { method: 'POST' });
expect(discard.status).toBe(401);
});
it('starts an active session', async () => {
const app = createApp();
const token = await authToken(app, 'sess-start@example.com');
const activityId = await seedActivity('Frezen');
const res = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(typeof body.id).toBe('number');
expect(body.activity_id).toBe(activityId);
expect(body.insole_type).toBe('Kurk');
expect(body.pair_count).toBe(2);
expect(body.status).toBe('active');
expect(body.source).toBe('app');
expect(body.end_time).toBeNull();
expect(body.duration_seconds).toBeNull();
expect(typeof body.start_time).toBe('string');
expect(typeof body.user_id).toBe('string');
});
it('400s start with a bad body', async () => {
const app = createApp();
const token = await authToken(app, 'sess-badbody@example.com');
const res = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
it('404s start for a missing activity', async () => {
const app = createApp();
const token = await authToken(app, 'sess-missingact@example.com');
const res = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ activity_id: 999999, insole_type: 'Kurk', pair_count: 2 }),
});
expect(res.status).toBe(404);
});
it('completes a session and computes duration', async () => {
const app = createApp();
const token = await authToken(app, 'sess-complete@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: 'Berk', pair_count: 2 }),
});
const started = await startRes.json();
// Make the duration deterministic: backdate the start time by exactly 5s.
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.status).toBe('completed');
expect(body.end_time).not.toBeNull();
expect(body.duration_seconds).toBe(5);
});
it('409s stopping an already-completed session', async () => {
const app = createApp();
const token = await authToken(app, 'sess-doublestop@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();
const first = await app.request(`/api/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(token),
});
expect(first.status).toBe(200);
const second = await app.request(`/api/sessions/${started.id}/stop`, {
method: 'POST',
headers: bearer(token),
});
expect(second.status).toBe(409);
});
it('discards an active session', async () => {
const app = createApp();
const token = await authToken(app, 'sess-discard@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();
const res = await app.request(`/api/sessions/${started.id}/discard`, {
method: 'POST',
headers: bearer(token),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('discarded');
expect(body.duration_seconds).toBeNull();
});
it("does not let user B stop user A's session", async () => {
const app = createApp();
const tokenA = await authToken(app, 'sess-ownerA@example.com');
const tokenB = await authToken(app, 'sess-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}/stop`, {
method: 'POST',
headers: bearer(tokenB),
});
expect(res.status).toBe(404);
// Verify via db the session is still active.
const [row] = await db.select().from(workSessions).where(eq(workSessions.id, started.id));
expect(row.status).toBe('active');
expect(row.endTime).toBeNull();
});
});
describe('session reads', () => {
it('401s GET /api/sessions and /api/sessions/active without a token', async () => {
const app = createApp();
const history = await app.request('/api/sessions');
expect(history.status).toBe(401);
const active = await app.request('/api/sessions/active');
expect(active.status).toBe(401);
});
it("returns the user's sessions joined with activity name, newest first", async () => {
const app = createApp();
const token = await authToken(app, 'reads-history@example.com');
const frezenId = await seedActivity('Frezen');
const slijpenId = await seedActivity('Slijpen');
const firstRes = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ activity_id: frezenId, insole_type: 'Kurk', pair_count: 2 }),
});
const first = await firstRes.json();
const secondRes = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ activity_id: slijpenId, insole_type: 'Berk', pair_count: 2 }),
});
const second = await secondRes.json();
// Control ordering: make the Slijpen session clearly newer.
await db
.update(workSessions)
.set({ startTime: new Date(Date.now() - 10000) })
.where(eq(workSessions.id, first.id));
await db
.update(workSessions)
.set({ startTime: new Date(Date.now() - 1000) })
.where(eq(workSessions.id, second.id));
const res = await app.request('/api/sessions', { headers: bearer(token) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveLength(2);
expect(new Date(body[0].start_time).getTime()).toBeGreaterThan(
new Date(body[1].start_time).getTime()
);
expect(body[0].activity_name).toBe('Slijpen');
expect(body[1].activity_name).toBe('Frezen');
});
it('scopes history to the requesting user', async () => {
const app = createApp();
const tokenA = await authToken(app, 'reads-scopeA@example.com');
const tokenB = await authToken(app, 'reads-scopeB@example.com');
const activityId = await seedActivity('Bekleden');
const startRes = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(tokenA),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
});
const aSession = await startRes.json();
const res = await app.request('/api/sessions', { headers: bearer(tokenB) });
expect(res.status).toBe(200);
const body = await res.json();
const ids = body.map((s: { id: number }) => s.id);
expect(ids).not.toContain(aSession.id);
});
it('returns only active sessions from /api/sessions/active', async () => {
const app = createApp();
const token = await authToken(app, 'reads-active@example.com');
const activityId = await seedActivity('Afwerken');
// One session stays active.
const activeRes = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }),
});
const activeStarted = await activeRes.json();
// Another is started then stopped (completed).
const stoppedRes = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ activity_id: activityId, insole_type: 'Berk', pair_count: 2 }),
});
const stoppedStarted = await stoppedRes.json();
await app.request(`/api/sessions/${stoppedStarted.id}/stop`, {
method: 'POST',
headers: bearer(token),
});
const res = await app.request('/api/sessions/active', { headers: bearer(token) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveLength(1);
expect(body[0].id).toBe(activeStarted.id);
expect(body[0].status).toBe('active');
});
});