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'; // Gemockte pool: sessie-join levert `user`, daarna beantwoordt `respond` // (per test) de queries; alles wat die niet afvangt geeft lege rows. function makeApp({ user = null, respond = () => undefined } = {}) { const calls = []; const pool = { async query(sql, params = []) { calls.push({ sql, params }); if (sql.includes('FROM sessions s JOIN users u')) return { rows: user ? [user] : [] }; if (sql.includes('FROM user_roles')) return { rows: [] }; const r = respond(sql, params); if (r) return r; 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 cookies = { teach_session: 'x'.repeat(64) }; const parent = { id: 20, username: 'ouder', role: 'parent', school_id: null, class_id: null, data: {}, data_rev: 0 }; const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 }; const homeChild = { id: 30, username: 'kind', role: 'pupil', school_id: null, class_id: null, data: {}, data_rev: 0 }; test('ouder kan zich registreren en is direct ingelogd', async () => { const { app, calls } = await makeApp({ respond: (sql) => { if (sql.includes("VALUES ($1,'parent'")) return { rows: [{ id: 20, username: 'ouder', role: 'parent', school_id: null }] }; return undefined; } }); const res = await app.inject({ method: 'POST', url: '/api/auth/register-parent', payload: { username: 'ouder', password: 'geheim123' } }); assert.equal(res.statusCode, 200, res.body); assert.equal(res.json().user.role, 'parent'); assert.ok(res.cookies.some((c) => c.name === 'teach_session')); assert.ok(calls.some((c) => c.sql.includes('INSERT INTO sessions'))); await app.close(); }); test('registratie weigert zwak wachtwoord en dubbele naam', async () => { const { app } = await makeApp(); const res = await app.inject({ method: 'POST', url: '/api/auth/register-parent', payload: { username: 'ouder', password: 'kort' } }); assert.equal(res.statusCode, 400); const { app: app2 } = await makeApp({ respond: (sql) => { if (sql.includes("VALUES ($1,'parent'")) { const e = new Error('dup'); e.code = '23505'; throw e; } return undefined; } }); const res2 = await app2.inject({ method: 'POST', url: '/api/auth/register-parent', payload: { username: 'ouder', password: 'geheim123' } }); assert.equal(res2.statusCode, 409); await app.close(); await app2.close(); }); test('ouder ziet gekoppelde kinderen met school en aanmeldstatus', async () => { const { app } = await makeApp({ user: parent, respond: (sql) => { if (sql.includes('FROM parent_children pc JOIN users u')) return { rows: [ { id: 30, username: 'kind', display_name: 'Kind', school_id: null, class_id: null, school_name: null, class_name: null }, { id: 31, username: 'zus', display_name: 'Zus', school_id: 2, class_id: 5, school_name: 'De Regenboog', class_name: 'Groep 4' }, ] }; if (sql.includes('FROM school_link_requests r JOIN schools s')) return { rows: [ { pupil_id: 30, school_name: 'De Regenboog' }, ] }; return undefined; } }); const res = await app.inject({ method: 'GET', url: '/api/parent/children', cookies }); assert.equal(res.statusCode, 200, res.body); const kids = res.json().children; assert.equal(kids.length, 2); assert.equal(kids[0].appliedTo, 'De Regenboog'); assert.equal(kids[1].schoolName, 'De Regenboog'); assert.equal(kids[1].className, 'Groep 4'); await app.close(); }); test('parent-routes zijn niet voor andere rollen', async () => { const { app } = await makeApp({ user: admin }); const res = await app.inject({ method: 'GET', url: '/api/parent/children', cookies }); assert.equal(res.statusCode, 403); await app.close(); }); test('ouder maakt een thuiskind aan en krijgt het wachtwoord eenmalig', async () => { const { app, calls } = await makeApp({ user: parent, respond: (sql) => { if (sql.includes("VALUES ($1,'pupil',NULL")) return { rows: [{ id: 30, username: 'kind', role: 'pupil', school_id: null }] }; return undefined; } }); const res = await app.inject({ method: 'POST', url: '/api/parent/children', cookies, payload: { name: 'kind' } }); assert.equal(res.statusCode, 200, res.body); assert.ok(res.json().child.password, 'wachtwoord hoort in de respons'); const link = calls.find((c) => c.sql.includes('INSERT INTO parent_children')); assert.deepEqual(link.params, [20, 30]); await app.close(); }); test('ouder-code koppelt eenmalig en vervalt daarna', async () => { const { app, calls } = await makeApp({ user: parent, respond: (sql) => { if (sql.includes('WHERE parent_code = $1')) return { rows: [{ id: 40, username: 'schoolkind', role: 'pupil', school_id: 2 }] }; return undefined; } }); const res = await app.inject({ method: 'POST', url: '/api/parent/children/link', cookies, payload: { code: 'k7fp-3rzm' } }); assert.equal(res.statusCode, 200, res.body); assert.ok(calls.some((c) => c.sql.includes('INSERT INTO parent_children'))); const clear = calls.find((c) => c.sql.includes('SET parent_code = NULL')); assert.deepEqual(clear.params, [40]); // onbekende code: 404 const { app: app2 } = await makeApp({ user: parent }); const res2 = await app2.inject({ method: 'POST', url: '/api/parent/children/link', cookies, payload: { code: 'XXXX-XXXX' } }); assert.equal(res2.statusCode, 404); await app.close(); await app2.close(); }); test('aanmelden bij een school maakt een verzoek; dubbel geeft 409', async () => { const respond = (sql, params) => { if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] }; if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] }; if (sql.startsWith('SELECT * FROM schools WHERE id')) return { rows: [{ id: 2, name: 'De Regenboog' }] }; return undefined; }; const { app, calls } = await makeApp({ user: parent, respond }); const res = await app.inject({ method: 'POST', url: '/api/parent/children/30/apply', cookies, payload: { schoolId: 2 } }); assert.equal(res.statusCode, 200, res.body); const ins = calls.find((c) => c.sql.includes('INSERT INTO school_link_requests')); assert.deepEqual(ins.params, [2, 20, 30]); // zelfde school nogmaals: unieke index -> 409 met nette melding const { app: app2 } = await makeApp({ user: parent, respond: (sql, params) => { if (sql.includes('INSERT INTO school_link_requests')) { const e = new Error('dup'); e.code = '23505'; throw e; } return respond(sql, params); } }); const res2 = await app2.inject({ method: 'POST', url: '/api/parent/children/30/apply', cookies, payload: { schoolId: 2 } }); assert.equal(res2.statusCode, 409); assert.match(res2.json().error, /al aangemeld/); await app.close(); await app2.close(); }); test('aanmelden kan niet voor een kind dat al op een school zit of niet gekoppeld is', async () => { const { app } = await makeApp({ user: parent, respond: (sql) => { if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] }; if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ ...homeChild, school_id: 2 }] }; return undefined; } }); const res = await app.inject({ method: 'POST', url: '/api/parent/children/30/apply', cookies, payload: { schoolId: 2 } }); assert.equal(res.statusCode, 400); const { app: app2 } = await makeApp({ user: parent }); // geen koppeling const res2 = await app2.inject({ method: 'POST', url: '/api/parent/children/30/apply', cookies, payload: { schoolId: 2 } }); assert.equal(res2.statusCode, 403); await app.close(); await app2.close(); }); test('staf genereert een ouder-code voor een leerling van de eigen school', async () => { const { app, calls } = await makeApp({ user: admin, respond: (sql) => { if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 40, role: 'pupil', school_id: 2, class_id: 5 }] }; return undefined; } }); const res = await app.inject({ method: 'POST', url: '/api/admin/users/40/parent-code', cookies }); assert.equal(res.statusCode, 200, res.body); assert.match(res.json().parentCode, /^[A-Z2-9]{4}-[A-Z2-9]{4}$/); assert.ok(calls.some((c) => c.sql.includes('SET parent_code = $1'))); await app.close(); }); test('ouders zelf kunnen geen ouder-code genereren', async () => { const { app } = await makeApp({ user: parent }); const res = await app.inject({ method: 'POST', url: '/api/admin/users/40/parent-code', cookies }); assert.equal(res.statusCode, 403); await app.close(); }); test('school ziet openstaande aanmeldingen met kind- en oudernaam', async () => { const { app } = await makeApp({ user: admin, respond: (sql) => { if (sql.includes('FROM school_link_requests r')) return { rows: [ { id: 1, created_at: 'nu', pupil_id: 30, pupil_username: 'kind', pupil_name: 'Kind', parent_username: 'ouder', parent_name: 'Ouder' }, ] }; return undefined; } }); const res = await app.inject({ method: 'GET', url: '/api/admin/link-requests', cookies }); assert.equal(res.statusCode, 200, res.body); const r = res.json().requests[0]; assert.equal(r.pupil.displayName, 'Kind'); assert.equal(r.parent.displayName, 'Ouder'); await app.close(); }); const request = { id: 1, school_id: 2, parent_id: 20, pupil_id: 30 }; test('adopteren zet school en klas op het thuisaccount en ruimt verzoeken op', async () => { const { app, calls } = await makeApp({ user: admin, respond: (sql) => { if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 5, school_id: 2, name: 'Groep 4' }] }; return undefined; } }); const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, payload: { action: 'adopt', classId: 5 } }); assert.equal(res.statusCode, 200, res.body); const upd = calls.find((c) => c.sql.includes('UPDATE users SET school_id')); assert.deepEqual(upd.params, [2, 5, null, 30]); assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM school_link_requests WHERE pupil_id'))); await app.close(); }); test('adopteren weigert een klas van een andere school en meldt naamconflict', async () => { const { app } = await makeApp({ user: admin, respond: (sql) => { if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 9, school_id: 3, name: 'Elders' }] }; return undefined; } }); const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, payload: { action: 'adopt', classId: 9 } }); assert.equal(res.statusCode, 400); const { app: app2 } = await makeApp({ user: admin, respond: (sql) => { if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; if (sql.includes('UPDATE users SET school_id')) { const e = new Error('dup'); e.code = '23505'; throw e; } return undefined; } }); const res2 = await app2.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, payload: { action: 'adopt' } }); assert.equal(res2.statusCode, 409); assert.match(res2.json().error, /bestaat al/); await app.close(); await app2.close(); }); test('koppelen aan bestaande leerling verhuist de ouderkoppeling; leeg thuisaccount wordt opgeruimd', async () => { const { app, calls } = await makeApp({ user: admin, respond: (sql) => { if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 41, role: 'pupil', school_id: 2 }] }; if (sql.includes('AS empty FROM users')) return { rows: [{ empty: true }] }; return undefined; } }); const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, payload: { action: 'match', pupilId: 41 } }); assert.equal(res.statusCode, 200, res.body); const move = calls.find((c) => c.sql.includes('INSERT INTO parent_children')); assert.deepEqual(move.params, [41, 30]); assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM users WHERE id'))); await app.close(); }); test('thuisaccount met borddata blijft bestaan na koppelen', async () => { const { app, calls } = await makeApp({ user: admin, respond: (sql) => { if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 41, role: 'pupil', school_id: 2 }] }; if (sql.includes('AS empty FROM users')) return { rows: [{ empty: false }] }; return undefined; } }); const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, payload: { action: 'match', pupilId: 41 } }); assert.equal(res.statusCode, 200, res.body); assert.ok(!calls.some((c) => c.sql.startsWith('DELETE FROM users WHERE id'))); assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM school_link_requests WHERE pupil_id'))); await app.close(); }); test('afwijzen verwijdert alleen het verzoek; andere school mag niet afhandelen', async () => { const { app, calls } = await makeApp({ user: admin, respond: (sql) => { if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; return undefined; } }); const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, payload: { action: 'reject' } }); assert.equal(res.statusCode, 200, res.body); assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM school_link_requests WHERE id'))); const { app: app2 } = await makeApp({ user: { ...admin, school_id: 9 }, respond: (sql) => { if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; return undefined; } }); const res2 = await app2.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, payload: { action: 'reject' } }); assert.equal(res2.statusCode, 403); await app.close(); await app2.close(); }); test('thuiskind logt voortgang met de gekoppelde ouder als leerkracht', async () => { const { app, calls } = await makeApp({ user: homeChild, respond: (sql) => { if (sql.includes('FROM parent_children WHERE pupil_id')) return { rows: [{ parent_id: 20 }] }; return undefined; } }); const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { widgetId: 'w1', widgetType: 'math', attempts: 5, correct: 4, stars: 2 } }); assert.equal(res.statusCode, 200, res.body); const ins = calls.find((c) => c.sql.includes('INSERT INTO progress_events')); assert.equal(ins.params[1], 20); assert.equal(ins.params[2], 'thuis'); await app.close(); }); test('gekoppelde ouder ziet de voortgang van het kind, een vreemde ouder niet', async () => { const { app } = await makeApp({ user: parent, respond: (sql) => { if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] }; if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] }; if (sql.startsWith('SELECT * FROM progress_events')) return { rows: [] }; return undefined; } }); const res = await app.inject({ method: 'GET', url: '/api/progress/pupil/30', cookies }); assert.equal(res.statusCode, 200, res.body); const { app: app2 } = await makeApp({ user: parent, respond: (sql) => { if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] }; return undefined; } }); const res2 = await app2.inject({ method: 'GET', url: '/api/progress/pupil/30', cookies }); assert.equal(res2.statusCode, 403); await app2.close(); await app.close(); });