All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m28s
391 lines
16 KiB
JavaScript
391 lines
16 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 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`);
|
|
}
|
|
});
|