import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { runInNewContext } from 'node:vm'; import Fastify from 'fastify'; import cookie from '@fastify/cookie'; import rateLimit from '@fastify/rate-limit'; import api from '../src/api.js'; /* Klassenmanagement: niveaugroep (groep 1-8) + niveau (1-3) per leerling per vakgebied. Zelfde soort dekking als test/progress-logging.test.js voor het server-pad, plus een echte (niet regex-)test van de SLO-getalgrenzen die math.js gebruikt zodra een leerling een groep heeft. */ function makeApp(user, { classRow = { id: 100, school_id: 2, name: 'Groep 4' }, classDefault = null, pupilOverride = null, classPupilOverrides = [] } = {}) { const calls = []; const query = async (sql, params = []) => { calls.push({ sql, params }); if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] }; if (sql.includes('FROM user_roles')) return { rows: [] }; if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: classRow ? [classRow] : [] }; if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [user] }; if (sql === 'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2') return { rows: classDefault ? [classDefault] : [] }; if (sql === 'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2') return { rows: pupilOverride ? [pupilOverride] : [] }; if (sql.includes('FROM pupil_levels pl JOIN users u')) return { rows: classPupilOverrides }; if (sql.startsWith('INSERT INTO pupil_levels')) return { rows: [] }; if (sql.startsWith('DELETE FROM pupil_levels')) return { rows: [] }; return { rows: [] }; }; const pool = { query, async connect() { return { query, release() {} }; } }; 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 teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0 }; const admin = { id: 4, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 }; const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 100, data: {}, data_rev: 0 }; const homePupil = { id: 10, username: 'thuis', role: 'pupil', school_id: null, class_id: null, data: {}, data_rev: 0 }; test('klassenmanagement: klas-standaard opslaan doet de juiste upsert-query', async () => { const { app, calls } = await makeApp(admin); const res = await app.inject({ method: 'PUT', url: '/api/levels/class/100', cookies, payload: { subject: 'creatief', yearGroup: 4, level: 2 }, }); assert.equal(res.statusCode, 200, res.body); const ins = calls.find(c => c.sql.startsWith('INSERT INTO pupil_levels')); assert.ok(ins); assert.deepEqual(ins.params, [100, 'creatief', 4, 2]); }); test('klassenmanagement: ongeldig vakgebied geeft 400', async () => { const { app } = await makeApp(teacher); const res = await app.inject({ method: 'PUT', url: '/api/levels/class/100', cookies, payload: { subject: 'gym', yearGroup: 4, level: 2 }, }); assert.equal(res.statusCode, 400, res.body); }); test('klassenmanagement: groep buiten 1-8 geeft 400', async () => { const { app } = await makeApp(teacher); const res = await app.inject({ method: 'PUT', url: '/api/levels/class/100', cookies, payload: { subject: 'rekenen', yearGroup: 12, level: 2 }, }); assert.equal(res.statusCode, 400, res.body); }); test('klassenmanagement: alleen de drie beschreven uitwerkingen zijn geldig', async () => { const { app } = await makeApp(teacher); const res = await app.inject({ method: 'PUT', url: '/api/levels/class/100', cookies, payload: { subject: 'rekenen', yearGroup: 4, level: 4 }, }); assert.equal(res.statusCode, 400, res.body); }); test('klassenmanagement: GET class levert klas-standaard én leerling-overrides in één keer', async () => { const { app } = await makeApp(admin, { classDefault: { year_group: 4, level: 2 }, classPupilOverrides: [{ pupil_id: 9, year_group: 6, level: 3 }], }); const res = await app.inject({ method: 'GET', url: '/api/levels/class/100?subject=rekenen', cookies }); assert.equal(res.statusCode, 200, res.body); const body = res.json(); assert.deepEqual(body.classDefault, { yearGroup: 4, level: 2 }); assert.deepEqual(body.pupilOverrides, [{ pupilId: 9, yearGroup: 6, level: 3 }]); }); test('klassenmanagement: /my/level - leerling-override wint van klas-standaard', async () => { const { app } = await makeApp(pupil, { pupilOverride: { year_group: 6, level: 3 } }); const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies }); assert.equal(res.statusCode, 200, res.body); assert.deepEqual(res.json(), { yearGroup: 6, level: 3 }); }); test('klassenmanagement: /my/level - valt terug op de klas-standaard zonder eigen override', async () => { const { app } = await makeApp(pupil, { classDefault: { year_group: 4, level: 2 } }); const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies }); assert.equal(res.statusCode, 200, res.body); assert.deepEqual(res.json(), { yearGroup: 4, level: 2 }); }); test('klassenmanagement: /my/level - niets ingesteld geeft de bestaande widget-standaardwaarde', async () => { const { app } = await makeApp(pupil); const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies }); assert.equal(res.statusCode, 200, res.body); assert.deepEqual(res.json(), { yearGroup: null, level: 1 }); }); test('klassenmanagement: leerkracht-rol wordt geweigerd op /my/level (alleen leerlingen)', async () => { const { app } = await makeApp(teacher); const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies }); assert.equal(res.statusCode, 403, res.body); }); test('thuiskind kan eigen creatief niveau opslaan, schoolleerling niet', async () => { const { app, calls } = await makeApp(homePupil); const res = await app.inject({ method: 'PUT', url: '/api/my/level', cookies, payload: { subject: 'creatief', yearGroup: 5, level: 2 } }); assert.equal(res.statusCode, 200, res.body); const insert = calls.find(call=>call.sql.startsWith('INSERT INTO pupil_levels')); assert.deepEqual(insert.params,[10,'creatief',5,2]); const { app: schoolApp } = await makeApp(pupil); const denied = await schoolApp.inject({ method: 'PUT', url: '/api/my/level', cookies, payload: { subject: 'creatief', yearGroup: 5, level: 2 } }); assert.equal(denied.statusCode,403,denied.body); await app.close();await schoolApp.close(); }); /* Echte (niet regex-)test van de SLO-getalgrenzen: data.js's SLO_MATH_RANGES/ mathRangeFor zijn pure functies zonder DOM-afhankelijkheid, dus rechtstreeks uitvoerbaar in een geïsoleerde VM-context - zelfde techniek als elders in test/widgets.test.js voor MONEY_LEVELS/createMoneyTask. */ test('SLO-rekengrenzen: groep bepaalt de daadwerkelijke getalgrens, niet meer de vaste 10/20/100-klem', async () => { const source = await readFile('public/js/data.js', 'utf8'), context = {}; runInNewContext(source + ';globalThis.__mathRange = mathRangeFor', context); const mathRangeFor = context.__mathRange; assert.equal(mathRangeFor(3, 3).addSub, 20, 'groep 3 t/m 20'); assert.equal(mathRangeFor(4, 3).addSub, 100, 'groep 4 t/m 100'); assert.equal(mathRangeFor(5, 3).addSub, 1000, 'groep 5 t/m 1000'); assert.equal(mathRangeFor(6, 3).addSub, 10000, 'groep 6 t/m 10.000'); // niveau is een verfijning BINNEN de groep: nooit hoger dan de groep-grens for (let g = 1; g <= 8; g++) { const r1 = mathRangeFor(g, 1), r2 = mathRangeFor(g, 2), r3 = mathRangeFor(g, 3); assert.ok(r1.addSub <= r2.addSub && r2.addSub <= r3.addSub, `groep ${g}: niveau moet oplopen`); assert.ok(r1.addSub >= 10, `groep ${g} niveau 1 nooit onder de kleinste bestaande standaardwaarde`); } // zonder groep (geen klassenmanagement ingesteld) geeft mathRangeFor niets terug - // math.js valt dan terug op zijn eigen oude vaste logica assert.equal(mathRangeFor(null, 2), null); assert.equal(mathRangeFor(undefined, 2), null); }); test('math.js: gen() gebruikt leerjaarregels en behoudt zonder groep de oude vaste klem', async () => { const source = await readFile('public/js/widgets/math.js', 'utf8'); assert.match(source, /const slo = mathRangeFor\(S\.yearGroup, L\)/); assert.match(source, /const rules = mathTaskRules\(S\.yearGroup, L\)/); assert.match(source, /const max = rules \? rules\.addSub/); assert.match(source, /const tmax = rules \? rules\.timesTable/); assert.ok(source.includes('S.yearGroup = initState.yearGroup || null')); });