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 teacher = { id: 3, username: 'juf', display_name: 'Juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0, }; const pupil = { id: 9, username: 'lena', display_name: 'Lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0, }; async function makeApp(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 auth(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('leerkracht kan een actieve leerling direct pauzeren', async () => { const calls = []; const query = async (sql, params = []) => { calls.push({ sql, params }); const login = auth(teacher, sql); if (login) return login; if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] }; if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ ok: 1 }] }; if (sql.startsWith('SELECT * FROM users WHERE id = $1 AND role = $2')) return { rows: [pupil] }; if (sql.includes('UPDATE live_learning_sessions SET paused_at = now()')) return { rows: [{ id: 101 }] }; if (sql.includes('INSERT INTO teacher_pupil_actions')) return { rows: [{ id: 301, class_id: 50, pupil_id: 9, teacher_id: 3, kind: 'pause', message: null, payload: {}, created_at: new Date(), }] }; return { rows: [] }; }; const app = await makeApp(teacher, query); const response = await app.inject({ method: 'POST', url: '/api/progress/live/class/50/pupil/9/action', cookies, payload: { kind: 'pause' }, }); assert.equal(response.statusCode, 200, response.body); assert.equal(response.json().action.kind, 'pause'); assert.ok(calls.some((call) => call.sql.includes("last_seen_at >= now() - interval '45 seconds'"))); const insert = calls.find((call) => call.sql.includes('INSERT INTO teacher_pupil_actions')); assert.deepEqual(insert.params.slice(0, 5), [50, 9, 3, 'pause', null]); await app.close(); }); test('directe actie wordt geweigerd voor een leerling buiten de gekozen klas', async () => { let inserted = false; const query = async (sql) => { const login = auth(teacher, sql); if (login) return login; if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] }; if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ ok: 1 }] }; if (sql.startsWith('SELECT * FROM users WHERE id = $1 AND role = $2')) return { rows: [{ ...pupil, class_id: 51 }] }; if (sql.includes('INSERT INTO teacher_pupil_actions')) inserted = true; return { rows: [] }; }; const app = await makeApp(teacher, query); const response = await app.inject({ method: 'POST', url: '/api/progress/live/class/50/pupil/9/action', cookies, payload: { kind: 'hint', message: 'Probeer het woord hardop te zeggen.' }, }); assert.equal(response.statusCode, 403); assert.equal(inserted, false); await app.close(); }); test('eenvoudiger maakt veilig een individuele niveau-override', async () => { const calls = []; const query = async (sql, params = []) => { calls.push({ sql, params }); const login = auth(teacher, sql); if (login) return login; if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] }; if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ ok: 1 }] }; if (sql.startsWith('SELECT * FROM users WHERE id = $1 AND role = $2')) return { rows: [pupil] }; if (sql.startsWith('SELECT * FROM pupil_levels WHERE pupil_id')) return { rows: [] }; if (sql.startsWith('SELECT * FROM pupil_levels WHERE class_id')) return { rows: [{ class_id: 50, subject: 'rekenen', year_group: 5, level: 2 }] }; if (sql.includes('INSERT INTO pupil_levels')) return { rows: [] }; if (sql.includes('INSERT INTO teacher_pupil_actions')) return { rows: [{ id: 302, kind: 'easier', message: null, payload: { subject: 'rekenen', yearGroup: 5, level: 1 }, created_at: new Date(), }] }; return { rows: [] }; }; const app = await makeApp(teacher, query); const response = await app.inject({ method: 'POST', url: '/api/progress/live/class/50/pupil/9/action', cookies, payload: { kind: 'easier', subject: 'rekenen' }, }); assert.equal(response.statusCode, 200, response.body); const upsert = calls.find((call) => call.sql.includes('INSERT INTO pupil_levels')); assert.deepEqual(upsert.params, [9, 'rekenen', 5, 1]); assert.deepEqual(response.json().action.payload, { subject: 'rekenen', yearGroup: 5, level: 1 }); await app.close(); }); test('gepauzeerde sessie accepteert geen nieuwe voortgang', async () => { let progressInserted = false; const assignment = { id: 80, teacher_id: 3, board_id: 'board-live', sequence: [], }; const query = async (sql) => { const login = auth(pupil, sql); if (login) return login; if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] }; if (sql.includes('SELECT paused_at FROM live_learning_sessions')) return { rows: [{ paused_at: new Date() }] }; if (sql.includes('INSERT INTO progress_events')) progressInserted = true; return { rows: [] }; }; const app = await makeApp(pupil, query); const response = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { widgetId: 'math-1', widgetType: 'madd', attempts: 2, correct: 2, liveSessionId: 101, }, }); assert.equal(response.statusCode, 409, response.body); assert.equal(progressInserted, false); await app.close(); }); test('leerling ontvangt duurzame acties en ziet de actuele pauzestatus', async () => { const query = async (sql) => { const login = auth(pupil, sql); if (login) return login; if (sql.includes('FROM teacher_pupil_actions')) return { rows: [{ id: 401, kind: 'hint', message: 'Splits het woord in klanken.', payload: {}, created_at: new Date(), }] }; if (sql.includes('SELECT id, paused_at FROM live_learning_sessions')) return { rows: [{ id: 101, paused_at: new Date() }] }; return { rows: [] }; }; const app = await makeApp(pupil, query); const response = await app.inject({ method: 'GET', url: '/api/my/live-actions', cookies, }); assert.equal(response.statusCode, 200, response.body); assert.equal(response.json().paused, true); assert.equal(response.json().actions[0].message, 'Splits het woord in klanken.'); await app.close(); }); test('grafisch overzicht bevat raster, signalen, funnel, grafieken, tijdlijn en acties', async () => { const [migration, server, dashboard, pupilActions, styles, i18n, admin, html, version, core] = await Promise.all([ readFile('db/023_live_classroom_interaction.sql', 'utf8'), readFile('src/live-classroom.js', 'utf8'), readFile('public/js/live-classroom-dashboard.js', 'utf8'), readFile('public/js/pupil-live-actions.js', 'utf8'), readFile('public/css/live-classroom.css', 'utf8'), readFile('public/js/live-classroom-i18n.js', 'utf8'), readFile('public/js/admin.js', 'utf8'), readFile('public/index.html', 'utf8'), readFile('VERSION', 'utf8'), readFile('public/js/core.js', 'utf8'), ]); for (const value of ['teacher_pupil_actions', 'last_progress_at', 'paused_at']) assert.ok(migration.includes(value), value); assert.match(server, /HELP_IDLE_MS/); assert.match(server, /correct \/ attempts < 0\.6/); assert.match(server, /pupilStreams/); assert.match(server, /current_date - interval '6 days'/); assert.match(server, /ACTION_KINDS/); for (const value of ['live-class-grid', 'renderLiveFunnel', 'renderLiveTrend', 'renderLiveTimeline', 'amActionEasier', 'amActionPause']) { assert.ok(dashboard.includes(value), value); } assert.match(pupilActions, /new EventSource\("\/api\/my\/live-actions\/stream"\)/); assert.match(pupilActions, /\/ack/); assert.match(styles, /pupil-session-paused/); assert.match(styles, /live-graphics-grid/); assert.match(admin, /pupil\/'\+pupilId\+'\/action/); assert.ok(html.indexOf('live-classroom-i18n.js') < html.indexOf('pupil-live-actions.js')); assert.ok(html.includes('css/live-classroom.css')); for (const key of ['amFunnelTitle', 'amTimelineTitle', 'amActionHint', 'pupilPausedTitle']) assert.equal((i18n.match(new RegExp(key + ':', 'g')) || []).length, 2, key); assert.ok(core.includes('const VERSION = "' + version.trim() + '"')); });