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('ouder leest en wijzigt het leerniveau van een gekoppeld thuiskind', async () => { const 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] }; if (sql.includes("subject IN ('taal','rekenen','creatief')")) return { rows: [ { subject:'taal',year_group:3,level:2 },{ subject:'rekenen',year_group:4,level:1 },{ subject:'creatief',year_group:5,level:3 }, ] }; return undefined; }; const {app,calls}=await makeApp({user:parent,respond}); const get=await app.inject({method:'GET',url:'/api/parent/children/30/levels',cookies}); assert.equal(get.statusCode,200,get.body); assert.deepEqual(get.json().levels,{taal:{yearGroup:3,level:2},rekenen:{yearGroup:4,level:1},creatief:{yearGroup:5,level:3}}); const put=await app.inject({method:'PUT',url:'/api/parent/children/30/level',cookies, payload:{subject:'rekenen',yearGroup:5,level:3}}); assert.equal(put.statusCode,200,put.body); const insert=calls.find(call=>call.sql.startsWith('INSERT INTO pupil_levels')); assert.deepEqual(insert.params,[30,'rekenen',5,3]); await app.close(); }); test('ouder kan schoolniveau of niveau van een vreemd kind niet overschrijven', 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 school=await app.inject({method:'PUT',url:'/api/parent/children/30/level',cookies, payload:{subject:'taal',yearGroup:4,level:2}}); assert.equal(school.statusCode,403,school.body); const {app:other}=await makeApp({user:parent}); const strange=await other.inject({method:'PUT',url:'/api/parent/children/99/level',cookies, payload:{subject:'taal',yearGroup:4,level:2}}); assert.equal(strange.statusCode,403,strange.body); await app.close();await other.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 pupil_levels')), 'de oude thuiskeuze vervalt wanneer de leerkracht de regie krijgt'); 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('schoolbeheerder ziet gekoppelde ouders (met kindernamen) in de gebruikerslijst', async () => { const { app, calls } = await makeApp({ user: admin, respond: (sql) => { if (sql.startsWith('SELECT * FROM users WHERE school_id')) return { rows: [ { id: 50, username: 'lena', role: 'pupil', school_id: 2, class_id: 5 }, ] }; if (sql.includes('JOIN parent_children pc ON pc.parent_id = p.id')) return { rows: [ { id: 20, username: 'ouder', display_name: 'Ouder', role: 'parent', school_id: null, class_id: null }, ] }; if (sql.includes('FROM parent_children pc') && sql.includes('WHERE pc.parent_id = ANY')) return { rows: [ { parent_id: 20, display_name: 'Lena', username: 'lena' }, ] }; return undefined; } }); const res = await app.inject({ method: 'GET', url: '/api/admin/users', cookies }); assert.equal(res.statusCode, 200, res.body); const p = res.json().users.find((u) => u.role === 'parent'); assert.ok(p, 'ouder hoort in de schoollijst te staan'); assert.deepEqual(p.childNames, ['Lena']); // regressie: Postgres weigert "SELECT DISTINCT ... ORDER BY lower(p.username)" // omdat die expressie niet letterlijk in de select-lijst staat (was stuk in // productie voor elke school-gescopede gebruikerslijst) - de sort-expressie // moet dus als kolom in de SELECT staan, niet alleen in de ORDER BY. const linkedParentsCall = calls.find((c) => c.sql.includes('JOIN parent_children pc ON pc.parent_id = p.id')); assert.match(linkedParentsCall.sql, /SELECT DISTINCT p\.\*, lower\(p\.username\) AS sort_key/); assert.match(linkedParentsCall.sql, /ORDER BY sort_key/); await app.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('thuiskind mag reservekopieƫn zien, schoolleerling niet', async () => { const { app } = await makeApp({ user: homeChild }); const res = await app.inject({ method: 'GET', url: '/api/me/data/versions', cookies }); assert.equal(res.statusCode, 200, res.body); const { app: app2 } = await makeApp({ user: { ...homeChild, school_id: 2 } }); const res2 = await app2.inject({ method: 'GET', url: '/api/me/data/versions', cookies }); assert.equal(res2.statusCode, 403); await app.close(); await app2.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(); });