test(api): Activities — fill gaps + lifecycle test
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
61
apps/api/test/integration/activities-lifecycle.test.ts
Normal file
61
apps/api/test/integration/activities-lifecycle.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user