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 boardData = { boards: { folders: [{ boards: [{ id: 'board-live', name: 'Live bord', data: { widgets: [{ id: 'letters', wid: 'letters-1', state: { words: ['boom'] } }] }, }] }] }, }; 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('echte widgetinteractie start een beveiligde live leersessie', async () => { const pupil = { id: 9, username: 'lena', display_name: 'Lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0, }; const assignment = { id: 80, class_id: 50, teacher_id: 3, board_id: 'board-live', widget_id: 'letters-1', mode: 'werken', sequence: [], }; const calls = []; const query = async (sql, params = []) => { calls.push({ sql, params }); const a = auth(pupil, sql); if (a) return a; if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] }; if (sql === 'SELECT data FROM users WHERE id = $1') return { rows: [{ data: boardData }] }; if (sql.includes('INSERT INTO live_learning_sessions')) return { rows: [{ id: 101, pupil_id: 9, teacher_id: 3, class_id: 50, assignment_id: 80, board_id: 'board-live', widget_id: 'letters-1', widget_type: 'letters', mode: 'werken', started_at: new Date(), last_seen_at: new Date(), }] }; return { rows: [] }; }; const app = await makeApp(pupil, query); const response = await app.inject({ method: 'POST', url: '/api/my/live-session/start', cookies, payload: { widgetId: 'letters-1', widgetType: 'letters', mode: 'werken' }, }); assert.equal(response.statusCode, 200, response.body); assert.equal(response.json().session.status, 'live'); const insert = calls.find((call) => call.sql.includes('INSERT INTO live_learning_sessions')); assert.deepEqual(insert.params, [9, 3, 50, 80, 'board-live', 'letters-1', 'letters', 'werken']); assert.ok(calls.some((call) => call.sql.includes('SET ended_at = now()')), 'een vorige open sessie wordt eerst recent gemaakt'); await app.close(); }); test('voortgang houdt dezelfde live sessie direct actueel', async () => { const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0, }; const calls = []; const query = async (sql, params = []) => { calls.push({ sql, params }); const a = auth(pupil, sql); if (a) return a; if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [{ id: 80, teacher_id: 3, board_id: 'board-live', sequence: [] }] }; if (sql.includes('INSERT INTO progress_events')) return { rows: [] }; if (sql.includes('progress_count = progress_count + 1')) return { rows: [{ class_id: 50 }] }; return { rows: [] }; }; const app = await makeApp(pupil, query); const response = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { widgetId: 'letters-1', widgetType: 'letters', attempts: 4, correct: 3, stars: 1, liveSessionId: 101, }, }); assert.equal(response.statusCode, 200, response.body); const update = calls.find((call) => call.sql.includes('progress_count = progress_count + 1')); assert.deepEqual(update.params, [101, 9, 4, 3]); await app.close(); }); test('leerkracht ziet nieuwe actieve en meest recente sessies van de eigen klas', async () => { const teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: boardData, data_rev: 0, }; const now = new Date(); const query = async (sql) => { const a = auth(teacher, sql); if (a) return a; 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.includes('FROM live_learning_sessions ls')) return { rows: [ { id: 101, pupil_id: 9, display_name: 'Lena', widget_id: 'letters-1', widget_type: 'letters', mode: 'werken', started_at: now, last_seen_at: now, progress_count: 1, attempts: 2, correct: 2, ended_at: null, }, { id: 100, pupil_id: 8, display_name: 'Sam', widget_id: 'math-1', widget_type: 'madd', mode: 'werken', started_at: new Date(now - 120000), last_seen_at: new Date(now - 60000), progress_count: 2, attempts: 4, correct: 3, ended_at: new Date(now - 60000), }, ] }; return { rows: [] }; }; const app = await makeApp(teacher, query); const response = await app.inject({ method: 'GET', url: '/api/progress/live/class/50', cookies, }); assert.equal(response.statusCode, 200, response.body); const body = response.json(); assert.equal(body.liveCount, 1); assert.deepEqual(body.sessions.map((session) => session.status), ['live', 'recent']); assert.equal(body.sessions[0].pupilName, 'Lena'); await app.close(); }); test('groepsleiding kan geen live sessies van een andere klas volgen', async () => { const teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: boardData, data_rev: 0, }; const query = async (sql) => { const a = auth(teacher, sql); if (a) return a; if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 51, school_id: 2 }] }; if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [] }; return { rows: [] }; }; const app = await makeApp(teacher, query); const response = await app.inject({ method: 'GET', url: '/api/progress/live/class/51', cookies, }); assert.equal(response.statusCode, 403); await app.close(); }); test('live dashboard gebruikt SSE, heartbeats, retentie en ongebufferde proxying', async () => { const [migration, server, pupil, dashboard, admin, html, nginx, core, version] = await Promise.all([ readFile('db/022_live_learning_sessions.sql', 'utf8'), readFile('src/live-classroom.js', 'utf8'), readFile('public/js/pupil.js', 'utf8'), readFile('public/js/live-classroom-dashboard.js', 'utf8'), readFile('public/js/admin.js', 'utf8'), readFile('public/index.html', 'utf8'), readFile('deploy/nginx.conf', 'utf8'), readFile('public/js/core.js', 'utf8'), readFile('VERSION', 'utf8'), ]); for (const column of ['started_at', 'last_seen_at', 'ended_at', 'progress_count']) assert.ok(migration.includes(column), column); assert.match(server, /text\/event-stream/); assert.match(server, /publish\(classStreams, classId, \x27sessions\x27\)/); assert.match(server, /interval '24 hours'/); assert.match(pupil, /pointerdown[\s\S]*ensurePupilLiveSession/); assert.match(pupil, /PUPIL_LIVE_HEARTBEAT_MS/); assert.match(pupil, /liveSessionId/); assert.match(dashboard, /new EventSource/); assert.match(dashboard, /setInterval[\s\S]*30000/); assert.match(admin, /progress\/live\/class/); assert.ok(html.indexOf('js/live-classroom-dashboard.js') < html.indexOf('js/admin.js')); assert.match(nginx, /location \/api\/progress\/live\/[\s\S]*proxy_buffering off/); for (const key of ['amLiveSessions', 'amLiveNew', 'amLiveRecent', 'amLiveConnected']) assert.equal((core.match(new RegExp(key + ':', 'g')) || []).length, 2, key); assert.ok(core.includes('const VERSION = "' + version.trim() + '"')); });