All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 35s
- Leerlingen stonden onder standaard-ingeklapte klasgroepen, dus met een gekozen school leek de lijst leeg terwijl de detailweergave de juiste school toonde. Klasgroepen staan nu standaard open zodra een school is gekozen (de schoolkeuze wás al het filter); bewust dichtklappen wordt onthouden - Losse klassen (school verwijderd) waren nergens zichtbaar: de systeemmanager ziet ze nu zonder gekozen school met een uitleg, en kan ze via een School-veld terugkoppelen; nieuwe PATCH /admin/classes/:id + GET ?school=none (super) met servertests - Bij het aan een school koppelen van een leerling blijft de klasindeling behouden als die klas al bij de doelschool hoort (eerst klas terugkoppelen, dan de leerlingen) - UI-consistentie: één uniforme control-hoogte (32px) voor álle knoppen, selects en invoervelden in Instellingen - einde aan de grote maatverschillen; T() valt terug op NL en dan de sleutelnaam zodat een knop nooit zonder tekst kan staan - Status in de gebruikerstabel als compacte glyph (● in gebruik / ○ wacht) met uitleg bij aanwijzen i.p.v. een afgekapte "nog niet ing…"-chip - Geverifieerd in headless Chromium: nieuwe suite (21 checks: zichtbaarheid met schoolfilter, losse klassen, geen lege knoppen, uniforme hoogte over 8 secties) + volledige regressie van 5 bestaande suites Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
214 lines
11 KiB
JavaScript
214 lines
11 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import Fastify from 'fastify';
|
|
import cookie from '@fastify/cookie';
|
|
import rateLimit from '@fastify/rate-limit';
|
|
import api from '../src/api.js';
|
|
|
|
function makeApp(user, targetUser) {
|
|
const calls = [];
|
|
const pool = {
|
|
async query(sql, params = []) {
|
|
calls.push({ sql, params });
|
|
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] };
|
|
if (sql.includes('FROM user_roles')) return { rows: [] };
|
|
if (sql.startsWith('UPDATE schools SET name')) return { rows: [{ id: params[1], name: params[0] }] };
|
|
if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 100, school_id: 2, name: 'Groep 4' }] };
|
|
if (sql.startsWith('SELECT * FROM users WHERE id') && targetUser) return { rows: [targetUser] };
|
|
return { rows: [] };
|
|
},
|
|
};
|
|
return (async () => {
|
|
const app = Fastify({ trustProxy: 2 });
|
|
await app.register(cookie);
|
|
await app.register(rateLimit, { global: false });
|
|
app.decorate('pg', pool);
|
|
await app.register(api, { prefix: '/api' });
|
|
await app.ready();
|
|
return { app, calls };
|
|
})();
|
|
}
|
|
|
|
/* variant waarin de scholen-existentiecheck slaagt */
|
|
function makeAppWithSchool(user, targetUser) {
|
|
return (async () => {
|
|
const { app, calls } = await makeApp(user, targetUser);
|
|
const pool = app.pg;
|
|
const orig = pool.query.bind(pool);
|
|
pool.query = async (sql, params = []) => {
|
|
if (sql.startsWith('SELECT 1 FROM schools')) { calls.push({ sql, params }); return { rows: [{ 1: 1 }] }; }
|
|
return orig(sql, params);
|
|
};
|
|
return { app, calls };
|
|
})();
|
|
}
|
|
|
|
const cookies = { teach_session: 'x'.repeat(64) };
|
|
const superUser = { id: 1, username: 'sm', role: 'super', school_id: null, class_id: null, data: {}, data_rev: 0 };
|
|
const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 };
|
|
|
|
test('systeemmanager kan een school hernoemen', async () => {
|
|
const { app, calls } = await makeApp(superUser);
|
|
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
|
|
payload: { name: 'De Nieuwe Naam' } });
|
|
assert.equal(res.statusCode, 200, res.body);
|
|
assert.equal(res.json().school.name, 'De Nieuwe Naam');
|
|
const upd = calls.find((c) => c.sql.startsWith('UPDATE schools SET name'));
|
|
assert.deepEqual(upd.params, ['De Nieuwe Naam', '5']);
|
|
await app.close();
|
|
});
|
|
|
|
test('schoolbeheerder mag geen scholen hernoemen', async () => {
|
|
const { app } = await makeApp(admin);
|
|
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
|
|
payload: { name: 'Hack' } });
|
|
assert.equal(res.statusCode, 403);
|
|
await app.close();
|
|
});
|
|
|
|
test('schoolbeheerder en systeemmanager kunnen aan een klas gekoppeld worden', async () => {
|
|
// schoolbeheerder van dezelfde school
|
|
const { app, calls } = await makeApp(superUser, { id: 3, role: 'admin', school_id: 2 });
|
|
const res = await app.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
|
|
payload: { userId: 3 } });
|
|
assert.equal(res.statusCode, 200, res.body);
|
|
assert.ok(calls.some((c) => c.sql.includes('INSERT INTO class_teachers')));
|
|
await app.close();
|
|
// systeemmanager (geen school) mag ook
|
|
const { app: app2 } = await makeApp(superUser, { id: 1, role: 'super', school_id: null });
|
|
const res2 = await app2.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
|
|
payload: { userId: 1 } });
|
|
assert.equal(res2.statusCode, 200, res2.body);
|
|
await app2.close();
|
|
// leerlingen niet, en staf van een andere school niet
|
|
const { app: app3 } = await makeApp(superUser, { id: 9, role: 'pupil', school_id: 2 });
|
|
const res3 = await app3.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
|
|
payload: { userId: 9 } });
|
|
assert.equal(res3.statusCode, 400);
|
|
await app3.close();
|
|
const { app: app4 } = await makeApp(superUser, { id: 4, role: 'admin', school_id: 3 });
|
|
const res4 = await app4.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
|
|
payload: { userId: 4 } });
|
|
assert.equal(res4.statusCode, 400);
|
|
await app4.close();
|
|
});
|
|
|
|
test('ouder-achtige rol krijgt geen promotie en geen klaskoppeling', async () => {
|
|
// rolwijziging: alleen staf → staf; een parent-account mag nooit promoveren
|
|
const { app, calls } = await makeApp(superUser, { id: 7, role: 'parent', school_id: null });
|
|
const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies,
|
|
payload: { role: 'admin' } });
|
|
assert.equal(res.statusCode, 200, res.body);
|
|
assert.ok(!calls.some((c) => c.sql.startsWith('UPDATE users SET role')),
|
|
'rolwijziging mag voor een ouder-account niet uitgevoerd worden');
|
|
await app.close();
|
|
// klaskoppeling: parent weigeren, net als pupil
|
|
const { app: app2, calls: calls2 } = await makeApp(superUser, { id: 7, role: 'parent', school_id: null });
|
|
const res2 = await app2.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
|
|
payload: { userId: 7 } });
|
|
assert.equal(res2.statusCode, 400);
|
|
assert.ok(!calls2.some((c) => c.sql.includes('INSERT INTO class_teachers')));
|
|
await app2.close();
|
|
});
|
|
|
|
test('systeemmanager koppelt een gebruiker aan een school en maakt hem weer los', async () => {
|
|
// koppelen: school bestaat -> update + klas-/toewijzing-opruiming
|
|
const { app, calls } = await makeApp(superUser, { id: 7, role: 'teacher', school_id: null, username: 'juf' });
|
|
const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies,
|
|
payload: { schoolId: 2 } });
|
|
assert.equal(res.statusCode, 404, res.body); // mock geeft geen schools-rij terug
|
|
const { app: app2, calls: calls2 } = await makeAppWithSchool(superUser, { id: 7, role: 'teacher', school_id: null, username: 'juf' });
|
|
const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies,
|
|
payload: { schoolId: 2 } });
|
|
assert.equal(res2.statusCode, 200, res2.body);
|
|
const upd = calls2.find((c) => c.sql.startsWith('UPDATE users SET school_id'));
|
|
assert.deepEqual(upd.params, [2, null, 7]);
|
|
assert.ok(calls2.some((c) => c.sql.includes('DELETE FROM class_teachers')));
|
|
await app.close(); await app2.close();
|
|
// losmaken: schoolId null, geen schools-check nodig; leerling verliest toewijzing
|
|
const { app: app3, calls: calls3 } = await makeApp(superUser, { id: 9, role: 'pupil', school_id: 2, class_id: 5, username: 'lena' });
|
|
const res3 = await app3.inject({ method: 'PATCH', url: '/api/admin/users/9', cookies,
|
|
payload: { schoolId: null } });
|
|
assert.equal(res3.statusCode, 200, res3.body);
|
|
const upd3 = calls3.find((c) => c.sql.startsWith('UPDATE users SET school_id'));
|
|
assert.deepEqual(upd3.params, [null, null, 9]);
|
|
assert.ok(calls3.some((c) => c.sql.includes('DELETE FROM assignments WHERE pupil_id')));
|
|
await app3.close();
|
|
});
|
|
|
|
test('school koppelen kan niet door een schoolbeheerder en niet op super/parent', async () => {
|
|
const { app, calls } = await makeAppWithSchool(admin, { id: 7, role: 'teacher', school_id: 2, username: 'juf' });
|
|
await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, payload: { schoolId: 3 } });
|
|
assert.ok(!calls.some((c) => c.sql.startsWith('UPDATE users SET school_id')), 'admin mag geen school wijzigen');
|
|
await app.close();
|
|
const { app: app2, calls: calls2 } = await makeAppWithSchool(superUser, { id: 20, role: 'parent', school_id: null, username: 'ouder' });
|
|
await app2.inject({ method: 'PATCH', url: '/api/admin/users/20', cookies, payload: { schoolId: 2 } });
|
|
assert.ok(!calls2.some((c) => c.sql.startsWith('UPDATE users SET school_id')), 'parent blijft schoolloos');
|
|
await app2.close();
|
|
});
|
|
|
|
test('school koppelen meldt een naamconflict netjes als 409', async () => {
|
|
const calls = [];
|
|
const pool = {
|
|
async query(sql, params = []) {
|
|
calls.push({ sql, params });
|
|
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [superUser] };
|
|
if (sql.includes('FROM user_roles')) return { rows: [] };
|
|
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 7, role: 'teacher', school_id: null, username: 'juf' }] };
|
|
if (sql.startsWith('SELECT 1 FROM schools')) return { rows: [{ 1: 1 }] };
|
|
if (sql.startsWith('UPDATE users SET school_id')) { const e = new Error('dup'); e.code = '23505'; throw e; }
|
|
return { rows: [] };
|
|
},
|
|
};
|
|
const app = Fastify({ trustProxy: 2 });
|
|
await app.register(cookie);
|
|
await app.register(rateLimit, { global: false });
|
|
app.decorate('pg', pool);
|
|
await app.register(api, { prefix: '/api' });
|
|
await app.ready();
|
|
const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, payload: { schoolId: 2 } });
|
|
assert.equal(res.statusCode, 409);
|
|
assert.match(res.json().error, /bestaat al/);
|
|
await app.close();
|
|
});
|
|
|
|
test('systeemmanager koppelt een losse klas terug aan een school', async () => {
|
|
const { app, calls } = await makeAppWithSchool(superUser, null);
|
|
const origQuery = app.pg.query.bind(app.pg);
|
|
app.pg.query = async (sql, params = []) => {
|
|
if (sql.startsWith('SELECT * FROM classes WHERE id')) { calls.push({ sql, params }); return { rows: [{ id: 9, name: 'Groep 4', school_id: null }] }; }
|
|
return origQuery(sql, params);
|
|
};
|
|
const res = await app.inject({ method: 'PATCH', url: '/api/admin/classes/9', cookies,
|
|
payload: { schoolId: 2 } });
|
|
assert.equal(res.statusCode, 200, res.body);
|
|
const upd = calls.find((c) => c.sql.startsWith('UPDATE classes SET school_id'));
|
|
assert.deepEqual(upd.params, [2, 9]);
|
|
// schoolbeheerder mag dit niet (schools.manage = super)
|
|
const { app: app2, calls: calls2 } = await makeAppWithSchool(admin, null);
|
|
const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/classes/9', cookies,
|
|
payload: { schoolId: 2 } });
|
|
assert.equal(res2.statusCode, 403);
|
|
assert.ok(!calls2.some((c) => c.sql.startsWith('UPDATE classes SET school_id')));
|
|
await app.close(); await app2.close();
|
|
});
|
|
|
|
test('losse klassen zijn opvraagbaar met school=none (alleen super)', async () => {
|
|
const { app, calls } = await makeApp(superUser);
|
|
const res = await app.inject({ url: '/api/admin/classes?school=none', cookies });
|
|
assert.equal(res.statusCode, 200, res.body);
|
|
const q = calls.find((c) => c.sql.includes('FROM classes c'));
|
|
assert.ok(q.sql.includes('school_id IS NULL'), 'filtert op klassen zonder school');
|
|
await app.close();
|
|
});
|
|
|
|
test('hernoemen weigert een lege of te lange naam', async () => {
|
|
const { app } = await makeApp(superUser);
|
|
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
|
|
payload: { name: ' ' } });
|
|
assert.equal(res.statusCode, 400);
|
|
const res2 = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
|
|
payload: { name: 'x'.repeat(90) } });
|
|
assert.equal(res2.statusCode, 400);
|
|
await app.close();
|
|
});
|