teach/test/learning-paths.test.js
Ramon ae8d0d4bb4
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m23s
feat: voeg leerpaden dashboard en avatarbeloningen toe
2026-07-23 21:23:54 +02:00

223 lines
9.1 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('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`);
}
});