All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 47s
208 lines
8.9 KiB
JavaScript
208 lines
8.9 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 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() + '"'));
|
|
});
|