teach/test/parent-portal.test.js
Ramon 0841962824
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 44s
feat: voeg thematische kleurplaten toe (v0.4.65-beta)
2026-07-28 22:34:15 +02:00

398 lines
20 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';
// Gemockte pool: sessie-join levert `user`, daarna beantwoordt `respond`
// (per test) de queries; alles wat die niet afvangt geeft lege rows.
function makeApp({ user = null, respond = () => undefined } = {}) {
const calls = [];
const pool = {
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: user ? [user] : [] };
if (sql.includes('FROM user_roles')) return { rows: [] };
const r = respond(sql, params);
if (r) return r;
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 };
})();
}
const cookies = { teach_session: 'x'.repeat(64) };
const parent = { id: 20, username: 'ouder', role: 'parent', 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 };
const homeChild = { id: 30, username: 'kind', role: 'pupil', school_id: null, class_id: null, data: {}, data_rev: 0 };
test('ouder kan zich registreren en is direct ingelogd', async () => {
const { app, calls } = await makeApp({ respond: (sql) => {
if (sql.includes("VALUES ($1,'parent'")) return { rows: [{ id: 20, username: 'ouder', role: 'parent', school_id: null }] };
return undefined;
} });
const res = await app.inject({ method: 'POST', url: '/api/auth/register-parent',
payload: { username: 'ouder', password: 'geheim123' } });
assert.equal(res.statusCode, 200, res.body);
assert.equal(res.json().user.role, 'parent');
assert.ok(res.cookies.some((c) => c.name === 'teach_session'));
assert.ok(calls.some((c) => c.sql.includes('INSERT INTO sessions')));
await app.close();
});
test('registratie weigert zwak wachtwoord en dubbele naam', async () => {
const { app } = await makeApp();
const res = await app.inject({ method: 'POST', url: '/api/auth/register-parent',
payload: { username: 'ouder', password: 'kort' } });
assert.equal(res.statusCode, 400);
const { app: app2 } = await makeApp({ respond: (sql) => {
if (sql.includes("VALUES ($1,'parent'")) { const e = new Error('dup'); e.code = '23505'; throw e; }
return undefined;
} });
const res2 = await app2.inject({ method: 'POST', url: '/api/auth/register-parent',
payload: { username: 'ouder', password: 'geheim123' } });
assert.equal(res2.statusCode, 409);
await app.close(); await app2.close();
});
test('ouder ziet gekoppelde kinderen met school en aanmeldstatus', async () => {
const { app } = await makeApp({ user: parent, respond: (sql) => {
if (sql.includes('FROM parent_children pc JOIN users u')) return { rows: [
{ id: 30, username: 'kind', display_name: 'Kind', school_id: null, class_id: null, school_name: null, class_name: null },
{ id: 31, username: 'zus', display_name: 'Zus', school_id: 2, class_id: 5, school_name: 'De Regenboog', class_name: 'Groep 4' },
] };
if (sql.includes('FROM school_link_requests r JOIN schools s')) return { rows: [
{ pupil_id: 30, school_name: 'De Regenboog' },
] };
return undefined;
} });
const res = await app.inject({ method: 'GET', url: '/api/parent/children', cookies });
assert.equal(res.statusCode, 200, res.body);
const kids = res.json().children;
assert.equal(kids.length, 2);
assert.equal(kids[0].appliedTo, 'De Regenboog');
assert.equal(kids[1].schoolName, 'De Regenboog');
assert.equal(kids[1].className, 'Groep 4');
await app.close();
});
test('ouder leest en wijzigt het leerniveau van een gekoppeld thuiskind', async () => {
const respond = (sql) => {
if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] };
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] };
if (sql.includes("subject IN ('taal','rekenen','creatief')")) return { rows: [
{ subject:'taal',year_group:3,level:2 },{ subject:'rekenen',year_group:4,level:1 },{ subject:'creatief',year_group:5,level:3 },
] };
return undefined;
};
const {app,calls}=await makeApp({user:parent,respond});
const get=await app.inject({method:'GET',url:'/api/parent/children/30/levels',cookies});
assert.equal(get.statusCode,200,get.body);
assert.deepEqual(get.json().levels,{taal:{yearGroup:3,level:2},rekenen:{yearGroup:4,level:1},creatief:{yearGroup:5,level:3}});
const put=await app.inject({method:'PUT',url:'/api/parent/children/30/level',cookies,
payload:{subject:'rekenen',yearGroup:5,level:3}});
assert.equal(put.statusCode,200,put.body);
const insert=calls.find(call=>call.sql.startsWith('INSERT INTO pupil_levels'));
assert.deepEqual(insert.params,[30,'rekenen',5,3]);
await app.close();
});
test('ouder kan schoolniveau of niveau van een vreemd kind niet overschrijven', async () => {
const {app}=await makeApp({user:parent,respond:(sql)=>{
if(sql.includes('FROM parent_children WHERE parent_id'))return{rows:[{1:1}]};
if(sql.startsWith('SELECT * FROM users WHERE id'))return{rows:[{...homeChild,school_id:2}]};
return undefined;
}});
const school=await app.inject({method:'PUT',url:'/api/parent/children/30/level',cookies,
payload:{subject:'taal',yearGroup:4,level:2}});
assert.equal(school.statusCode,403,school.body);
const {app:other}=await makeApp({user:parent});
const strange=await other.inject({method:'PUT',url:'/api/parent/children/99/level',cookies,
payload:{subject:'taal',yearGroup:4,level:2}});
assert.equal(strange.statusCode,403,strange.body);
await app.close();await other.close();
});
test('parent-routes zijn niet voor andere rollen', async () => {
const { app } = await makeApp({ user: admin });
const res = await app.inject({ method: 'GET', url: '/api/parent/children', cookies });
assert.equal(res.statusCode, 403);
await app.close();
});
test('ouder maakt een thuiskind aan en krijgt het wachtwoord eenmalig', async () => {
const { app, calls } = await makeApp({ user: parent, respond: (sql) => {
if (sql.includes("VALUES ($1,'pupil',NULL")) return { rows: [{ id: 30, username: 'kind', role: 'pupil', school_id: null }] };
return undefined;
} });
const res = await app.inject({ method: 'POST', url: '/api/parent/children', cookies,
payload: { name: 'kind' } });
assert.equal(res.statusCode, 200, res.body);
assert.ok(res.json().child.password, 'wachtwoord hoort in de respons');
const link = calls.find((c) => c.sql.includes('INSERT INTO parent_children'));
assert.deepEqual(link.params, [20, 30]);
await app.close();
});
test('ouder-code koppelt eenmalig en vervalt daarna', async () => {
const { app, calls } = await makeApp({ user: parent, respond: (sql) => {
if (sql.includes('WHERE parent_code = $1')) return { rows: [{ id: 40, username: 'schoolkind', role: 'pupil', school_id: 2 }] };
return undefined;
} });
const res = await app.inject({ method: 'POST', url: '/api/parent/children/link', cookies,
payload: { code: 'k7fp-3rzm' } });
assert.equal(res.statusCode, 200, res.body);
assert.ok(calls.some((c) => c.sql.includes('INSERT INTO parent_children')));
const clear = calls.find((c) => c.sql.includes('SET parent_code = NULL'));
assert.deepEqual(clear.params, [40]);
// onbekende code: 404
const { app: app2 } = await makeApp({ user: parent });
const res2 = await app2.inject({ method: 'POST', url: '/api/parent/children/link', cookies,
payload: { code: 'XXXX-XXXX' } });
assert.equal(res2.statusCode, 404);
await app.close(); await app2.close();
});
test('aanmelden bij een school maakt een verzoek; dubbel geeft 409', async () => {
const respond = (sql, params) => {
if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] };
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] };
if (sql.startsWith('SELECT * FROM schools WHERE id')) return { rows: [{ id: 2, name: 'De Regenboog' }] };
return undefined;
};
const { app, calls } = await makeApp({ user: parent, respond });
const res = await app.inject({ method: 'POST', url: '/api/parent/children/30/apply', cookies,
payload: { schoolId: 2 } });
assert.equal(res.statusCode, 200, res.body);
const ins = calls.find((c) => c.sql.includes('INSERT INTO school_link_requests'));
assert.deepEqual(ins.params, [2, 20, 30]);
// zelfde school nogmaals: unieke index -> 409 met nette melding
const { app: app2 } = await makeApp({ user: parent, respond: (sql, params) => {
if (sql.includes('INSERT INTO school_link_requests')) { const e = new Error('dup'); e.code = '23505'; throw e; }
return respond(sql, params);
} });
const res2 = await app2.inject({ method: 'POST', url: '/api/parent/children/30/apply', cookies,
payload: { schoolId: 2 } });
assert.equal(res2.statusCode, 409);
assert.match(res2.json().error, /al aangemeld/);
await app.close(); await app2.close();
});
test('aanmelden kan niet voor een kind dat al op een school zit of niet gekoppeld is', async () => {
const { app } = await makeApp({ user: parent, respond: (sql) => {
if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] };
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ ...homeChild, school_id: 2 }] };
return undefined;
} });
const res = await app.inject({ method: 'POST', url: '/api/parent/children/30/apply', cookies,
payload: { schoolId: 2 } });
assert.equal(res.statusCode, 400);
const { app: app2 } = await makeApp({ user: parent }); // geen koppeling
const res2 = await app2.inject({ method: 'POST', url: '/api/parent/children/30/apply', cookies,
payload: { schoolId: 2 } });
assert.equal(res2.statusCode, 403);
await app.close(); await app2.close();
});
test('staf genereert een ouder-code voor een leerling van de eigen school', async () => {
const { app, calls } = await makeApp({ user: admin, respond: (sql) => {
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 40, role: 'pupil', school_id: 2, class_id: 5 }] };
return undefined;
} });
const res = await app.inject({ method: 'POST', url: '/api/admin/users/40/parent-code', cookies });
assert.equal(res.statusCode, 200, res.body);
assert.match(res.json().parentCode, /^[A-Z2-9]{4}-[A-Z2-9]{4}$/);
assert.ok(calls.some((c) => c.sql.includes('SET parent_code = $1')));
await app.close();
});
test('ouders zelf kunnen geen ouder-code genereren', async () => {
const { app } = await makeApp({ user: parent });
const res = await app.inject({ method: 'POST', url: '/api/admin/users/40/parent-code', cookies });
assert.equal(res.statusCode, 403);
await app.close();
});
test('school ziet openstaande aanmeldingen met kind- en oudernaam', async () => {
const { app } = await makeApp({ user: admin, respond: (sql) => {
if (sql.includes('FROM school_link_requests r')) return { rows: [
{ id: 1, created_at: 'nu', pupil_id: 30, pupil_username: 'kind', pupil_name: 'Kind', parent_username: 'ouder', parent_name: 'Ouder' },
] };
return undefined;
} });
const res = await app.inject({ method: 'GET', url: '/api/admin/link-requests', cookies });
assert.equal(res.statusCode, 200, res.body);
const r = res.json().requests[0];
assert.equal(r.pupil.displayName, 'Kind');
assert.equal(r.parent.displayName, 'Ouder');
await app.close();
});
const request = { id: 1, school_id: 2, parent_id: 20, pupil_id: 30 };
test('adopteren zet school en klas op het thuisaccount en ruimt verzoeken op', async () => {
const { app, calls } = await makeApp({ user: admin, respond: (sql) => {
if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] };
if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 5, school_id: 2, name: 'Groep 4' }] };
return undefined;
} });
const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies,
payload: { action: 'adopt', classId: 5 } });
assert.equal(res.statusCode, 200, res.body);
const upd = calls.find((c) => c.sql.includes('UPDATE users SET school_id'));
assert.deepEqual(upd.params, [2, 5, null, 30]);
assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM pupil_levels')),
'de oude thuiskeuze vervalt wanneer de leerkracht de regie krijgt');
assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM school_link_requests WHERE pupil_id')));
await app.close();
});
test('adopteren weigert een klas van een andere school en meldt naamconflict', async () => {
const { app } = await makeApp({ user: admin, respond: (sql) => {
if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] };
if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 9, school_id: 3, name: 'Elders' }] };
return undefined;
} });
const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies,
payload: { action: 'adopt', classId: 9 } });
assert.equal(res.statusCode, 400);
const { app: app2 } = await makeApp({ user: admin, respond: (sql) => {
if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] };
if (sql.includes('UPDATE users SET school_id')) { const e = new Error('dup'); e.code = '23505'; throw e; }
return undefined;
} });
const res2 = await app2.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies,
payload: { action: 'adopt' } });
assert.equal(res2.statusCode, 409);
assert.match(res2.json().error, /bestaat al/);
await app.close(); await app2.close();
});
test('koppelen aan bestaande leerling verhuist de ouderkoppeling; leeg thuisaccount wordt opgeruimd', async () => {
const { app, calls } = await makeApp({ user: admin, respond: (sql) => {
if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] };
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 41, role: 'pupil', school_id: 2 }] };
if (sql.includes('AS empty FROM users')) return { rows: [{ empty: true }] };
return undefined;
} });
const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies,
payload: { action: 'match', pupilId: 41 } });
assert.equal(res.statusCode, 200, res.body);
const move = calls.find((c) => c.sql.includes('INSERT INTO parent_children'));
assert.deepEqual(move.params, [41, 30]);
assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM users WHERE id')));
await app.close();
});
test('thuisaccount met borddata blijft bestaan na koppelen', async () => {
const { app, calls } = await makeApp({ user: admin, respond: (sql) => {
if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] };
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 41, role: 'pupil', school_id: 2 }] };
if (sql.includes('AS empty FROM users')) return { rows: [{ empty: false }] };
return undefined;
} });
const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies,
payload: { action: 'match', pupilId: 41 } });
assert.equal(res.statusCode, 200, res.body);
assert.ok(!calls.some((c) => c.sql.startsWith('DELETE FROM users WHERE id')));
assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM school_link_requests WHERE pupil_id')));
await app.close();
});
test('afwijzen verwijdert alleen het verzoek; andere school mag niet afhandelen', async () => {
const { app, calls } = await makeApp({ user: admin, respond: (sql) => {
if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] };
return undefined;
} });
const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies,
payload: { action: 'reject' } });
assert.equal(res.statusCode, 200, res.body);
assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM school_link_requests WHERE id')));
const { app: app2 } = await makeApp({ user: { ...admin, school_id: 9 }, respond: (sql) => {
if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] };
return undefined;
} });
const res2 = await app2.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies,
payload: { action: 'reject' } });
assert.equal(res2.statusCode, 403);
await app.close(); await app2.close();
});
test('schoolbeheerder ziet gekoppelde ouders (met kindernamen) in de gebruikerslijst', async () => {
const { app, calls } = await makeApp({ user: admin, respond: (sql) => {
if (sql.startsWith('SELECT * FROM users WHERE school_id')) return { rows: [
{ id: 50, username: 'lena', role: 'pupil', school_id: 2, class_id: 5 },
] };
if (sql.includes('JOIN parent_children pc ON pc.parent_id = p.id')) return { rows: [
{ id: 20, username: 'ouder', display_name: 'Ouder', role: 'parent', school_id: null, class_id: null },
] };
if (sql.includes('FROM parent_children pc') && sql.includes('WHERE pc.parent_id = ANY')) return { rows: [
{ parent_id: 20, display_name: 'Lena', username: 'lena' },
] };
return undefined;
} });
const res = await app.inject({ method: 'GET', url: '/api/admin/users', cookies });
assert.equal(res.statusCode, 200, res.body);
const p = res.json().users.find((u) => u.role === 'parent');
assert.ok(p, 'ouder hoort in de schoollijst te staan');
assert.deepEqual(p.childNames, ['Lena']);
// regressie: Postgres weigert "SELECT DISTINCT ... ORDER BY lower(p.username)"
// omdat die expressie niet letterlijk in de select-lijst staat (was stuk in
// productie voor elke school-gescopede gebruikerslijst) - de sort-expressie
// moet dus als kolom in de SELECT staan, niet alleen in de ORDER BY.
const linkedParentsCall = calls.find((c) => c.sql.includes('JOIN parent_children pc ON pc.parent_id = p.id'));
assert.match(linkedParentsCall.sql, /SELECT DISTINCT p\.\*, lower\(p\.username\) AS sort_key/);
assert.match(linkedParentsCall.sql, /ORDER BY sort_key/);
await app.close();
});
test('thuiskind logt voortgang met de gekoppelde ouder als leerkracht', async () => {
const { app, calls } = await makeApp({ user: homeChild, respond: (sql) => {
if (sql.includes('FROM parent_children WHERE pupil_id')) return { rows: [{ parent_id: 20 }] };
return undefined;
} });
const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies,
payload: { widgetId: 'w1', widgetType: 'math', attempts: 5, correct: 4, stars: 2 } });
assert.equal(res.statusCode, 200, res.body);
const ins = calls.find((c) => c.sql.includes('INSERT INTO progress_events'));
assert.equal(ins.params[1], 20);
assert.equal(ins.params[2], 'thuis');
await app.close();
});
test('thuiskind mag reservekopieën zien, schoolleerling niet', async () => {
const { app } = await makeApp({ user: homeChild });
const res = await app.inject({ method: 'GET', url: '/api/me/data/versions', cookies });
assert.equal(res.statusCode, 200, res.body);
const { app: app2 } = await makeApp({ user: { ...homeChild, school_id: 2 } });
const res2 = await app2.inject({ method: 'GET', url: '/api/me/data/versions', cookies });
assert.equal(res2.statusCode, 403);
await app.close(); await app2.close();
});
test('gekoppelde ouder ziet de voortgang van het kind, een vreemde ouder niet', async () => {
const { app } = await makeApp({ user: parent, respond: (sql) => {
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] };
if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] };
if (sql.startsWith('SELECT * FROM progress_events')) return { rows: [] };
return undefined;
} });
const res = await app.inject({ method: 'GET', url: '/api/progress/pupil/30', cookies });
assert.equal(res.statusCode, 200, res.body);
const { app: app2 } = await makeApp({ user: parent, respond: (sql) => {
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] };
return undefined;
} });
const res2 = await app2.inject({ method: 'GET', url: '/api/progress/pupil/30', cookies });
assert.equal(res2.statusCode, 403);
await app2.close(); await app.close();
});