import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import Fastify from 'fastify'; import cookie from '@fastify/cookie'; import rateLimit from '@fastify/rate-limit'; import api from '../src/api.js'; const cookies = { teach_session: 'x'.repeat(64) }; const teacherData = { boards: { folders: [{ boards: [{ id: 'board-1', name: 'Taalbord', data: { widgets: [ { id: 'letters', wid: 'letters-1', state: { words: ['boom'] } }, { id: 'sentence', wid: 'sentence-1', state: { level: 1 } }, ], }, }], }], }, }; async function fastifyWith(user, query) { const app = Fastify({ trustProxy: 2 }); await app.register(cookie); await app.register(rateLimit, { global: false }); app.decorate('pg', { query }); await app.register(api, { prefix: '/api' }); await app.ready(); return app; } function authRows(user, sql) { if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] }; if (sql.includes('FROM user_roles')) return { rows: [] }; return null; } test('opdrachtenreeks toont alleen de eerste nog niet afgeronde live widget', async () => { const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0, reward_points: 2, }; const assignment = { id: 80, pupil_id: 9, teacher_id: 3, board_id: 'board-1', widget_id: 'letters-1', mode: 'werken', title: 'Taalroute', updated_at: new Date('2026-07-23T08:00:00Z'), sequence: [ { id: 'step-1', boardId: 'board-1', widgetId: 'letters-1', mode: 'werken' }, { id: 'step-2', boardId: 'board-1', widgetId: 'sentence-1', mode: 'kijken' }, ], }; const query = async (sql) => { const auth = authRows(pupil, sql); if (auth) return auth; if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] }; if (sql.startsWith('SELECT step_id') && sql.includes('FROM assignment_step_progress')) return { rows: [{ step_id: 'step-1' }] }; if (sql === 'SELECT data FROM users WHERE id = $1') return { rows: [{ data: teacherData }] }; return { rows: [] }; }; const app = await fastifyWith(pupil, query); const response = await app.inject({ method: 'GET', url: '/api/my/assignment', cookies }); assert.equal(response.statusCode, 200, response.body); const body = response.json(); assert.equal(body.widgets.length, 1); assert.equal(body.widgets[0].wid, 'sentence-1'); assert.equal(body.mode, 'kijken'); assert.deepEqual(body.sequence, { title: 'Taalroute', steps: [ { id: 'step-1', mode: 'werken', completed: true }, { id: 'step-2', mode: 'kijken', completed: false }, ], currentIndex: 1, completed: 1, total: 2, done: false, ready: true, }); await app.close(); }); test('stapafronding is opeenvolgend en kent punt en nieuwe beloning ÊÊn keer toe', async () => { const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0, reward_points: 2, }; const assignment = { id: 80, pupil_id: 9, teacher_id: 3, board_id: 'board-1', updated_at: new Date(), sequence: [ { id: 'step-1', boardId: 'board-1', widgetId: 'letters-1', mode: 'werken' }, { id: 'step-2', boardId: 'board-1', widgetId: 'sentence-1', mode: 'kijken' }, ], }; const calls = []; const query = async (sql, params = []) => { calls.push({ sql, params }); const auth = authRows(pupil, sql); if (auth) return auth; if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] }; if (sql.startsWith('SELECT step_id') && sql.includes('FROM assignment_step_progress')) return { rows: [{ step_id: 'step-1' }] }; if (sql.startsWith('INSERT INTO assignment_step_progress')) return { rows: [{ step_id: 'step-2' }] }; if (sql.startsWith('UPDATE users SET reward_points')) return { rows: [{ reward_points: 3 }] }; if (sql.startsWith('INSERT INTO user_avatar_rewards')) return { rows: [{ reward_key: 'body:medal' }] }; return { rows: [] }; }; const app = await fastifyWith(pupil, query); const skipped = await app.inject({ method: 'POST', url: '/api/my/assignment/steps/step-1/complete', cookies, }); assert.equal(skipped.statusCode, 409); const response = await app.inject({ method: 'POST', url: '/api/my/assignment/steps/step-2/complete', cookies, }); assert.equal(response.statusCode, 200, response.body); assert.deepEqual(response.json(), { ok: true, points: 3, unlocked: ['body:medal'], done: true, }); assert.equal(calls.filter((call) => call.sql.startsWith('UPDATE users SET reward_points')).length, 1); await app.close(); }); test('leerkracht kan een gevalideerde reeks van maximaal twaalf stappen opslaan', async () => { const teacher = { id: 3, username: 'juf', display_name: 'Juf', role: 'teacher', school_id: 2, class_id: null, data: teacherData, data_rev: 0, }; const calls = []; const query = async (sql, params = []) => { calls.push({ sql, params }); const auth = authRows(teacher, sql); if (auth) return auth; if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] }; if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ '?column?': 1 }] }; if (sql.includes('INSERT INTO assignments')) return { rows: [{ id: 80 }] }; return { rows: [] }; }; const app = await fastifyWith(teacher, query); const payload = { boardId: 'board-1', title: 'Taalroute', sequence: [ { widgetId: 'letters-1', mode: 'werken' }, { widgetId: 'sentence-1', mode: 'kijken' }, ], }; const response = await app.inject({ method: 'PUT', url: '/api/assignments/class/50', cookies, payload, }); assert.equal(response.statusCode, 200, response.body); const insert = calls.find((call) => call.sql.includes('INSERT INTO assignments')); assert.ok(insert); assert.equal(insert.params[5], 'Taalroute'); assert.deepEqual(JSON.parse(insert.params[6]), [ { id: 'step-1', boardId: 'board-1', widgetId: 'letters-1', mode: 'werken' }, { id: 'step-2', boardId: 'board-1', widgetId: 'sentence-1', mode: 'kijken' }, ]); const tooLong = await app.inject({ method: 'PUT', url: '/api/assignments/class/50', cookies, payload: { ...payload, sequence: Array.from({ length: 13 }, () => payload.sequence[0]) }, }); assert.equal(tooLong.statusCode, 400); await app.close(); }); test('leerkracht maakt zonder bord een zelfstandige reeks met een thema per opdracht', async () => { const teacher = { id: 3, username: 'juf', display_name: 'Juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0, }; const calls = []; const query = async (sql, params = []) => { calls.push({ sql, params }); const auth = authRows(teacher, sql); if (auth) return auth; if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] }; if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ '?column?': 1 }] }; if (sql.includes('INSERT INTO assignments')) return { rows: [{ id: 81 }] }; return { rows: [] }; }; const app = await fastifyWith(teacher, query); const theme = { id: 'builtin:animals', name: 'Dieren', words: [['kat', '🐈', 1], ['hond', '🐕', 2]], }; const response = await app.inject({ method: 'PUT', url: '/api/assignments/class/50', cookies, payload: { title: 'Dierenroute', sequence: [ { widgetType: 'letters', mode: 'werken', theme }, { widgetType: 'madd', mode: 'werken' }, ], }, }); assert.equal(response.statusCode, 200, response.body); const insert = calls.find((call) => call.sql.includes('INSERT INTO assignments')); assert.equal(insert.params[2], 'assignment-library'); assert.equal(insert.params[3], 'assignment-step-1'); assert.deepEqual(JSON.parse(insert.params[6]), [ { id: 'step-1', source: 'library', boardId: 'assignment-library', widgetId: 'assignment-step-1', widgetType: 'letters', mode: 'werken', requiresProgress: true, theme: { id: 'builtin:animals', name: 'Dieren', words: [['kat', '🐈', 1, null], ['hond', '🐕', 2, null]], }, }, { id: 'step-2', source: 'library', boardId: 'assignment-library', widgetId: 'assignment-step-2', widgetType: 'madd', mode: 'werken', requiresProgress: true, theme: null, }, ]); assert.ok(!calls.some((call) => call.sql.includes('SELECT data FROM users')), 'zelfstandige opdrachten zoeken geen auteursbord op'); await app.close(); }); test('leerling ontvangt de opgeslagen thematische widget zonder auteursbord', async () => { const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0, reward_points: 0, }; const assignment = { id: 81, class_id: 50, teacher_id: 3, board_id: 'assignment-library', widget_id: 'assignment-step-1', mode: 'werken', title: 'Dierenroute', updated_at: new Date('2026-07-25T08:00:00Z'), sequence: [{ id: 'step-1', source: 'library', boardId: 'assignment-library', widgetId: 'assignment-step-1', widgetType: 'letters', mode: 'werken', requiresProgress: true, theme: { id: 'builtin:animals', name: 'Dieren', words: [['kat', '🐈', 1, null]] }, }], }; const calls = []; const query = async (sql, params = []) => { calls.push({ sql, params }); const auth = authRows(pupil, sql); if (auth) return auth; if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] }; if (sql.startsWith('SELECT step_id') && sql.includes('FROM assignment_step_progress')) return { rows: [] }; if (sql.startsWith('SELECT 1 FROM progress_events')) return { rows: [] }; return { rows: [] }; }; const app = await fastifyWith(pupil, query); const response = await app.inject({ method: 'GET', url: '/api/my/assignment', cookies }); assert.equal(response.statusCode, 200, response.body); const body = response.json(); assert.deepEqual(body.widgets, [{ id: 'letters', wid: 'assignment-step-1', state: { assignmentThemeId: 'builtin:animals', assignmentThemeName: 'Dieren', assignmentWords: [['kat', '🐈', 1, null]], }, }]); assert.deepEqual(body.sequence.steps, [{ id: 'step-1', mode: 'werken', widgetType: 'letters', themeName: 'Dieren', completed: false, }]); assert.ok(!calls.some((call) => call.sql.includes('SELECT data FROM users')), 'de leerlingweergave is niet afhankelijk van het bord van de leerkracht'); await app.close(); }); test('thema wordt alleen begrensd en bij geschikte woordwidgets geaccepteerd', async () => { const teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0, }; const query = async (sql) => { const auth = authRows(teacher, sql); if (auth) return auth; return { rows: [] }; }; const app = await fastifyWith(teacher, query); const response = await app.inject({ method: 'PUT', url: '/api/assignments/class/50', cookies, payload: { title: 'Onjuiste route', sequence: [{ widgetType: 'madd', mode: 'werken', theme: { id: 'builtin:animals', name: 'Dieren', words: [['kat', '🐈']] }, }], }, }); assert.equal(response.statusCode, 400); assert.match(response.json().error, /thema is niet beschikbaar/); await app.close(); }); test('zelfstandige widget zonder voortgangsevent kan handmatig worden afgerond', async () => { const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0, reward_points: 0, }; const assignment = { id: 82, class_id: 50, teacher_id: 3, board_id: 'assignment-library', widget_id: 'assignment-step-1', mode: 'werken', title: 'Flitsroute', updated_at: new Date('2026-07-25T08:00:00Z'), sequence: [{ id: 'step-1', source: 'library', boardId: 'assignment-library', widgetId: 'assignment-step-1', widgetType: 'flits', mode: 'werken', requiresProgress: false, theme: null, }], }; const calls = []; const query = async (sql, params = []) => { calls.push({ sql, params }); const auth = authRows(pupil, sql); if (auth) return auth; if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] }; if (sql.startsWith('SELECT step_id') && sql.includes('FROM assignment_step_progress')) return { rows: [] }; if (sql.startsWith('INSERT INTO assignment_step_progress')) return { rows: [{ step_id: 'step-1' }] }; if (sql.startsWith('UPDATE users SET reward_points')) return { rows: [{ reward_points: 1 }] }; if (sql.startsWith('INSERT INTO user_avatar_rewards')) return { rows: [] }; return { rows: [] }; }; const app = await fastifyWith(pupil, query); const current = await app.inject({ method: 'GET', url: '/api/my/assignment', cookies }); assert.equal(current.statusCode, 200, current.body); assert.equal(current.json().sequence.ready, true); const completed = await app.inject({ method: 'POST', url: '/api/my/assignment/steps/step-1/complete', cookies, }); assert.equal(completed.statusCode, 200, completed.body); assert.ok(!calls.some((call) => call.sql.startsWith('SELECT 1 FROM progress_events')), 'handmatig af te ronden widgets vragen geen niet-bestaand voortgangsevent'); await app.close(); }); test('dashboard markeert klaar, bezig, hulp nodig en nog niet gestart', async () => { const teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: teacherData, data_rev: 0, }; const query = async (sql) => { const auth = authRows(teacher, sql); if (auth) return auth; if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] }; if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ '?column?': 1 }] }; if (sql.includes('COALESCE(SUM(pe.attempts)')) return { rows: [ { id: 1, display_name: 'A', attempts: 10, correct: 10 }, { id: 2, display_name: 'B', attempts: 8, correct: 3 }, { id: 3, display_name: 'C', attempts: 2, correct: 2 }, { id: 4, display_name: 'D', attempts: 0, correct: 0 }, ] }; if (sql.startsWith('SELECT * FROM assignments')) return { rows: [{ id: 80, class_id: 50, title: 'Route', sequence: [{ id: 'step-1' }, { id: 'step-2' }], }] }; if (sql.includes('FROM assignment_step_progress asp')) return { rows: [ { assignment_id: 80, pupil_id: 1, completed: 2 }, ] }; return { rows: [] }; }; const app = await fastifyWith(teacher, query); const response = await app.inject({ method: 'GET', url: '/api/progress/dashboard/class/50', cookies, }); assert.equal(response.statusCode, 200, response.body); assert.deepEqual(response.json().pupils.map((pupil) => pupil.status), ['complete', 'support', 'active', 'notStarted']); assert.deepEqual(response.json().summary, { complete: 1, active: 1, support: 1, notStarted: 1 }); await app.close(); }); test('database, leerlingweergave en beheer bevatten de drie samenhangende uitbreidingen', async () => { const [migration, pupil, cast, admin, avatar, css, core] = await Promise.all([ readFile('db/021_learning_paths.sql', 'utf8'), readFile('public/js/pupil.js', 'utf8'), readFile('public/js/cast.js', 'utf8'), readFile('public/js/admin.js', 'utf8'), readFile('public/js/avatar.js', 'utf8'), readFile('public/css/teach.css', 'utf8'), readFile('public/js/core.js', 'utf8'), ]); for (const table of ['assignment_step_progress', 'user_avatar_rewards']) assert.ok(migration.includes(table)); assert.match(pupil, /pupil-path[\s\S]*assignment\/steps/); assert.match(cast, /castPathSteps[\s\S]*body\.sequence/); assert.match(admin, /progress\/dashboard\/class/); for (const reward of ['head:star', 'body:medal', 'dance:robot']) { assert.ok(avatar.includes(reward)); } assert.match(css, /av-dance-robot/); for (const key of ['pupilPathStep', 'castPathAdd', 'amDashboardTitle']) { assert.equal(core.split(`${key}:`).length - 1, 2, `${key} moet tweetalig zijn`); } });