teach/test/schools.test.js
Ramon c86abcf794
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 32s
fix: onzichtbare knopteksten, lege klassenlijst en correcte klas-schoolregels (v0.3.93-beta)
- Onzichtbare knoppen opgelost: .tbtn houdt standaard witte tekst (wit-op-blauw), maar in het beheer staat hij op een lichte achtergrond -> tekst was wit-op-wit. Nu expliciet donkere tekst; de primaire accentknop houdt bewust witte tekst
- Systeemmanager kreeg standaard "alle scholen" waardoor klassen leeg leken en niet toe te voegen waren; er wordt nu meteen een concrete school gekozen (klassen/gebruikers direct gevuld). Contextbalk laat wisselen; "alle scholen" blijft een bewuste keuze
- Lege school toont nu een duidelijke uitleg dat bestaande klassen bij een andere school kunnen horen, met verwijzing naar de schoolkeuze
- Correcte gegevensregels: classes.school_id is NOT NULL met ON DELETE CASCADE, dus een klas kan niet "los" bestaan (die eerdere aanname is teruggedraaid). Een klas is nu te VERPLAATSEN naar een andere school (PATCH weigert null/ongeldig); een verwijderde school neemt zijn klassen mee (cascade) - dat verklaart verdwenen klassen
- Bij een leerling naar een andere school koppelen blijft de klas behouden als die al bij de doelschool hoort
- Verificatie uitgebreid met een echte kleurcontrast-scan (vangt wit-op-wit) over alle secties + de default-school-flow; alle 94 servertests en 8 Playwright-suites groen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 20:22:36 +02:00

205 lines
10 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 verplaatst een klas naar een andere school; losmaken kan niet', async () => {
const { app, calls } = await makeAppWithSchool(superUser, null);
const res = await app.inject({ method: 'PATCH', url: '/api/admin/classes/100', cookies,
payload: { schoolId: 3 } });
assert.equal(res.statusCode, 200, res.body);
const upd = calls.find((c) => c.sql.startsWith('UPDATE classes SET school_id'));
assert.deepEqual(upd.params, [3, 100]);
// een klas hoort altijd bij een school: geen/null wordt geweigerd
const { app: app2 } = await makeAppWithSchool(superUser, null);
const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/classes/100', cookies,
payload: { schoolId: null } });
assert.equal(res2.statusCode, 400);
// schoolbeheerder mag dit niet (schools.manage = super)
const { app: app3, calls: calls3 } = await makeAppWithSchool(admin, null);
const res3 = await app3.inject({ method: 'PATCH', url: '/api/admin/classes/100', cookies,
payload: { schoolId: 3 } });
assert.equal(res3.statusCode, 403);
assert.ok(!calls3.some((c) => c.sql.startsWith('UPDATE classes SET school_id')));
await app.close(); await app2.close(); await app3.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();
});