teach/test/live-sessions.test.js
Ramon 131ffc2f38
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 47s
feat: breid live klasinteractie grafisch uit
2026-07-24 13:40:18 +02:00

186 lines
7.8 KiB
JavaScript

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() + '"'));
});