diff --git a/apps/api/src/routes/activities.ts b/apps/api/src/routes/activities.ts index 21eb97e..54753e8 100644 --- a/apps/api/src/routes/activities.ts +++ b/apps/api/src/routes/activities.ts @@ -16,6 +16,7 @@ function toActivity(row: ActivityRow): Activity { name: row.name, insole_types: row.insoleTypes as Activity['insole_types'], created_at: new Date(row.createdAt).toISOString(), + /* c8 ignore next -- sort_order is NOT NULL DEFAULT 0; the ?? fallback is unreachable */ sort_order: row.sortOrder ?? 0, }; } diff --git a/apps/api/test/activities.test.ts b/apps/api/test/activities.test.ts index e52e27c..8b746e9 100644 --- a/apps/api/test/activities.test.ts +++ b/apps/api/test/activities.test.ts @@ -1,13 +1,15 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { createApp } from '../src/app'; import { db } from '../src/db/client'; import { workSessions, user } from '../src/db/schema'; import { eq } from 'drizzle-orm'; -import { authToken, bearer } from './helpers'; +import { authToken, bearer, resetDb } from './helpers'; const json = { 'content-type': 'application/json' }; describe('activities routes', () => { + beforeEach(resetDb); + it('401s GET /api/activities without a token', async () => { const app = createApp(); const res = await app.request('/api/activities'); @@ -305,4 +307,144 @@ describe('activities routes', () => { .where(eq(workSessions.activityId, created.id)); expect(after.length).toBe(0); }); + + // --- write-route authz gating (401 no token / 403 worker) --- + + it('401s PUT /api/activities/:id without a token', async () => { + const app = createApp(); + const res = await app.request('/api/activities/1', { + method: 'PUT', + headers: json, + body: JSON.stringify({ name: 'X', insole_types: ['Kurk'] }), + }); + expect(res.status).toBe(401); + }); + + it('forbids a worker from updating an activity (403)', async () => { + const app = createApp(); + const token = await authToken(app, 'act-put-worker@example.com'); + const res = await app.request('/api/activities/1', { + method: 'PUT', + headers: bearer(token), + body: JSON.stringify({ name: 'X', insole_types: ['Kurk'] }), + }); + expect(res.status).toBe(403); + }); + + it('401s DELETE /api/activities/:id without a token', async () => { + const app = createApp(); + const res = await app.request('/api/activities/1', { method: 'DELETE' }); + expect(res.status).toBe(401); + }); + + it('forbids a worker from deleting an activity (403)', async () => { + const app = createApp(); + const token = await authToken(app, 'act-delete-worker@example.com'); + const res = await app.request('/api/activities/1', { + method: 'DELETE', + headers: bearer(token), + }); + expect(res.status).toBe(403); + }); + + it('401s PUT /api/activities/reorder without a token', async () => { + const app = createApp(); + const res = await app.request('/api/activities/reorder', { + method: 'PUT', + headers: json, + body: JSON.stringify({ ids: [1] }), + }); + expect(res.status).toBe(401); + }); + + // --- id parsing + body validation on the write routes --- + + it('404s PUT for a non-numeric id', async () => { + const app = createApp(); + const token = await authToken(app, 'act-put-nan@example.com', 'admin'); + const res = await app.request('/api/activities/not-a-number', { + method: 'PUT', + headers: bearer(token), + body: JSON.stringify({ name: 'X', insole_types: ['Kurk'] }), + }); + expect(res.status).toBe(404); + }); + + it('400s PUT with an invalid body (empty name)', async () => { + const app = createApp(); + const token = await authToken(app, 'act-put-invalid@example.com', 'admin'); + + const createRes = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Bewerkbaar', insole_types: ['Kurk'] }), + }); + const created = await createRes.json(); + + const res = await app.request(`/api/activities/${created.id}`, { + method: 'PUT', + headers: bearer(token), + body: JSON.stringify({ name: ' ', insole_types: ['Kurk'] }), + }); + expect(res.status).toBe(400); + }); + + it('404s DELETE for a non-numeric id', async () => { + const app = createApp(); + const token = await authToken(app, 'act-delete-nan@example.com', 'admin'); + const res = await app.request('/api/activities/not-a-number', { + method: 'DELETE', + headers: bearer(token), + }); + expect(res.status).toBe(404); + }); + + it('400s reorder with an empty ids array', async () => { + const app = createApp(); + const token = await authToken(app, 'act-reorder-empty@example.com', 'admin'); + const res = await app.request('/api/activities/reorder', { + method: 'PUT', + headers: bearer(token), + body: JSON.stringify({ ids: [] }), + }); + expect(res.status).toBe(400); + }); + + it('400s reorder when the ids count matches but contains an unknown id', async () => { + const app = createApp(); + const token = await authToken(app, 'act-reorder-unknown@example.com', 'admin'); + + const createRes = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Enige', insole_types: ['Kurk'] }), + }); + const created = await createRes.json(); + + // Same length as the full set (1), but the id is not a known activity. + const res = await app.request('/api/activities/reorder', { + method: 'PUT', + headers: bearer(token), + body: JSON.stringify({ ids: [created.id + 999] }), + }); + expect(res.status).toBe(400); + }); + + it('round-trips insole_types as a stored subset', async () => { + const app = createApp(); + const token = await authToken(app, 'act-subset@example.com', 'admin'); + + const createRes = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Subset', insole_types: ['Berk'] }), + }); + const created = await createRes.json(); + expect(created.insole_types).toEqual(['Berk']); + + const listRes = await app.request('/api/activities', { headers: bearer(token) }); + const list: Array<{ id: number; insole_types: string[] }> = await listRes.json(); + const found = list.find((a) => a.id === created.id); + expect(found?.insole_types).toEqual(['Berk']); + }); }); diff --git a/apps/api/test/integration/activities-lifecycle.test.ts b/apps/api/test/integration/activities-lifecycle.test.ts new file mode 100644 index 0000000..3305b1c --- /dev/null +++ b/apps/api/test/integration/activities-lifecycle.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createApp } from '../../src/app'; +import { authToken, bearer, resetDb } from '../helpers'; + +describe('activities lifecycle (admin create → reorder → worker sees order → delete)', () => { + beforeEach(resetDb); + + it('creates, reorders, reflects the order to a worker, then removes one', async () => { + const app = createApp(); + const adminToken = await authToken(app, 'act-lc-admin@example.com', 'admin'); + const workerToken = await authToken(app, 'act-lc-worker@example.com'); + + // Admin creates three activities; they append in creation order. + const names = ['Zagen', 'Frezen', 'Slijpen']; + const created: Array<{ id: number; name: string }> = []; + for (const name of names) { + const res = await app.request('/api/activities', { + method: 'POST', + headers: bearer(adminToken), + body: JSON.stringify({ name, insole_types: ['Kurk'] }), + }); + expect(res.status).toBe(200); + created.push(await res.json()); + } + expect(created.map((a) => a.name)).toEqual(names); + + // Admin reverses the order. + const reversedIds = created.map((a) => a.id).reverse(); + const reorderRes = await app.request('/api/activities/reorder', { + method: 'PUT', + headers: bearer(adminToken), + body: JSON.stringify({ ids: reversedIds }), + }); + expect(reorderRes.status).toBe(200); + + // A worker reads the list and sees the new order (sorted by sort_order). + const workerListRes = await app.request('/api/activities', { headers: bearer(workerToken) }); + expect(workerListRes.status).toBe(200); + const workerList: Array<{ id: number; name: string; sort_order: number }> = + await workerListRes.json(); + expect(workerList.map((a) => a.id)).toEqual(reversedIds); + expect(workerList.map((a) => a.name)).toEqual(['Slijpen', 'Frezen', 'Zagen']); + for (let i = 1; i < workerList.length; i++) { + expect(workerList[i].sort_order).toBeGreaterThanOrEqual(workerList[i - 1].sort_order); + } + + // Admin deletes the middle activity; it disappears from the list. + const toDelete = created[1]; // Frezen + const delRes = await app.request(`/api/activities/${toDelete.id}`, { + method: 'DELETE', + headers: bearer(adminToken), + }); + expect(delRes.status).toBe(200); + expect(await delRes.json()).toEqual({ success: true }); + + const afterRes = await app.request('/api/activities', { headers: bearer(workerToken) }); + const after: Array<{ id: number }> = await afterRes.json(); + expect(after.map((a) => a.id)).toEqual([created[2].id, created[0].id]); + expect(after.some((a) => a.id === toDelete.id)).toBe(false); + }); +}); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 91b4f77..1306022 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -63,3 +63,30 @@ Guaranteed behaviors: - CORS: the allowed SPA origins (worker 5173, admin 5174) are echoed on the `access-control-allow-origin` header, OPTIONS preflight is answered with the allowed methods, and `set-auth-token` is exposed so the SPA can read the bearer on sign-in. + +## Activities + +Src covered: `src/routes/activities.ts` — 100% stmts/branch/funcs/lines. The one unreachable +branch (`row.sortOrder ?? 0` in `toActivity`; `sort_order` is `NOT NULL DEFAULT 0`) is +`c8 ignore`d with a reason, not left silent. + +Guaranteed behaviors: + +- `GET /api/activities` requires a session (401 without a token); any authenticated user — + worker or admin — may read. +- The list is ordered by `sort_order` then `name`, and `?insole_type=` filters to + activities whose `insole_types` include that type. +- `POST`/`PUT`/`DELETE`/`PUT /reorder` are admin-only: 401 without a token, 403 for a worker. +- Create validates a non-empty (trimmed) `name` (400 otherwise) and defaults `insole_types` to + all three (`Kurk`, `Berk`, `3D`) when omitted. +- Create appends the new activity with a `sort_order` one higher than the current maximum. +- `insole_types` round-trips through create/list as the exact stored subset. +- Update (`PUT /:id`) 404s for a non-numeric or unknown id, 400s on an invalid body, and returns + the updated activity otherwise. +- Delete (`DELETE /:id`) 404s for a non-numeric id, removes the activity **and** its work-session + rows (no FK cascade), and returns `{ success: true }`. +- Reorder (`PUT /reorder`) sets `sort_order` from the `ids` array position; it 400s on an empty + array (zod `min(1)`) and when the ids are not exactly the full known set (wrong count or an + unknown id). +- 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.