import test from 'node:test'; import assert from 'node:assert/strict'; import Fastify from 'fastify'; import cookie from '@fastify/cookie'; import rateLimit from '@fastify/rate-limit'; import api from '../src/api.js'; // Ingelogde leerkracht via een gemockte sessie: elke query die de sessie-join // doet levert de gebruiker, de data-update legt zijn SQL+params vast zodat de // test de rev-voorwaarde en het 409-pad kan controleren. function makeApp({ dataRev = 3, updateSucceeds = true } = {}) { const calls = []; const pool = { async query(sql, params = []) { calls.push({ sql, params }); if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{ id: 7, username: 'docent', display_name: 'Docent', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: dataRev, }] }; if (sql.includes('FROM user_roles')) return { rows: [] }; if (sql.startsWith('SELECT data, data_rev')) return { rows: [{ data: { boards: {} }, data_rev: dataRev }] }; if (sql.startsWith('UPDATE users SET data =')) { return updateSucceeds ? { rows: [{ data_rev: dataRev + 1 }] } : { rows: [] }; } return { rows: [] }; }, }; return (async () => { const app = Fastify({ trustProxy: 2 }); await app.register(cookie); await app.register(rateLimit, { global: false }); app.decorate('pg', pool); await app.register(api, { prefix: '/api' }); await app.ready(); return { app, calls }; })(); } const sessionCookie = { teach_session: 'x'.repeat(64) }; test('GET /me/data geeft data én rev terug', async () => { const { app } = await makeApp({ dataRev: 3 }); const res = await app.inject({ method: 'GET', url: '/api/me/data', cookies: sessionCookie }); assert.equal(res.statusCode, 200, res.body); assert.deepEqual(res.json(), { data: { boards: {} }, rev: 3 }); await app.close(); }); test('PUT /me/data met actuele rev slaagt en geeft de nieuwe rev', async () => { const { app, calls } = await makeApp({ dataRev: 3 }); const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie, headers: { 'x-data-rev': '3' }, payload: { boards: {} } }); assert.equal(res.statusCode, 200, res.body); assert.equal(res.json().rev, 4); const upd = calls.find((c) => c.sql.startsWith('UPDATE users SET data =')); assert.ok(upd.sql.includes('AND data_rev = $3')); assert.equal(upd.params[2], 3); await app.close(); }); test('PUT /me/data met verouderde rev geeft 409', async () => { const { app } = await makeApp({ dataRev: 5, updateSucceeds: false }); const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie, headers: { 'x-data-rev': '3' }, payload: { boards: {} } }); assert.equal(res.statusCode, 409, res.body); assert.match(res.json().error, /ander tabblad/); await app.close(); }); test('PUT /me/data zonder rev-header slaat zonder voorwaarde op (legacy)', async () => { const { app, calls } = await makeApp({ dataRev: 3 }); const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie, payload: { boards: {} } }); assert.equal(res.statusCode, 200, res.body); const upd = calls.find((c) => c.sql.startsWith('UPDATE users SET data =')); assert.ok(!upd.sql.includes('AND data_rev')); await app.close(); }); test('PUT /me/data/:section vervangt alleen die sub-key via jsonb_set', async () => { const { app, calls } = await makeApp({ dataRev: 3 }); const res = await app.inject({ method: 'PUT', url: '/api/me/data/themes', cookies: sessionCookie, headers: { 'x-data-rev': '3' }, payload: { nl: [], en: [] } }); assert.equal(res.statusCode, 200, res.body); assert.equal(res.json().rev, 4); const upd = calls.find((c) => c.sql.startsWith('UPDATE users SET data =')); assert.ok(upd.sql.includes('jsonb_set')); assert.equal(upd.params[0], '{themes}'); assert.ok(upd.sql.includes('AND data_rev = $4')); assert.equal(upd.params[3], 3); await app.close(); }); test('PUT /me/data/:section weigert onbekende secties', async () => { const { app } = await makeApp({ dataRev: 3 }); const res = await app.inject({ method: 'PUT', url: '/api/me/data/hackme', cookies: sessionCookie, headers: { 'x-data-rev': '3' }, payload: {} }); assert.equal(res.statusCode, 404); await app.close(); }); test('PUT /me/data maakt eerst een snapshot en ruimt oude versies op', async () => { const { app, calls } = await makeApp({ dataRev: 3 }); const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie, headers: { 'x-data-rev': '3' }, payload: { boards: {} } }); assert.equal(res.statusCode, 200, res.body); const snapIdx = calls.findIndex((c) => c.sql.includes('INSERT INTO user_data_versions')); const cleanIdx = calls.findIndex((c) => c.sql.includes('DELETE FROM user_data_versions')); const updIdx = calls.findIndex((c) => c.sql.startsWith('UPDATE users SET data =')); assert.ok(snapIdx >= 0 && cleanIdx >= 0); assert.ok(snapIdx < updIdx, 'snapshot vóór de overschrijving'); assert.equal(calls[cleanIdx].params[1], 10); await app.close(); }); test('GET /me/data/versions weigert leerlingen', async () => { const { app } = await makeApp({ dataRev: 3 }); // aparte app met pupil-rol const pupilApp = await (async () => { const calls = []; const pool = { async query(sql, params = []) { calls.push({ sql, params }); if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{ id: 8, username: 'kind', role: 'pupil', school_id: 2, class_id: 5, data: {}, data_rev: 0, }] }; return { rows: [] }; } }; const a = Fastify({ trustProxy: 2 }); await a.register(cookie); await a.register(rateLimit, { global: false }); a.decorate('pg', pool); await a.register(api, { prefix: '/api' }); await a.ready(); return a; })(); const res = await pupilApp.inject({ method: 'GET', url: '/api/me/data/versions', cookies: sessionCookie }); assert.equal(res.statusCode, 403); await pupilApp.close(); await app.close(); }); test('POST /me/data/restore zet een versie terug en bewaart de huidige eerst', async () => { const calls = []; const pool = { async query(sql, params = []) { calls.push({ sql, params }); if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{ id: 7, username: 'docent', role: 'teacher', school_id: 2, class_id: null, data: { boards: { f: 1 } }, data_rev: 6, }] }; if (sql.includes('FROM user_roles')) return { rows: [] }; if (sql.startsWith('SELECT data FROM user_data_versions')) return { rows: [{ data: { boards: { f: 0 } } }] }; if (sql.startsWith('UPDATE users SET data =')) return { rows: [{ data_rev: 7 }] }; return { rows: [] }; } }; const app = Fastify({ trustProxy: 2 }); await app.register(cookie); await app.register(rateLimit, { global: false }); app.decorate('pg', pool); await app.register(api, { prefix: '/api' }); await app.ready(); const res = await app.inject({ method: 'POST', url: '/api/me/data/restore', cookies: sessionCookie, payload: { versionId: 12 } }); assert.equal(res.statusCode, 200, res.body); assert.deepEqual(res.json(), { data: { boards: { f: 0 } }, rev: 7 }); const snapIdx = calls.findIndex((c) => c.sql.includes('INSERT INTO user_data_versions')); const updIdx = calls.findIndex((c) => c.sql.startsWith('UPDATE users SET data =')); assert.ok(snapIdx >= 0 && snapIdx < updIdx, 'huidige data eerst bewaard'); await app.close(); });