Files
kita-planer/tests/notdienst-planning.integration.test.cjs

282 lines
18 KiB
JavaScript

/* eslint-disable @typescript-eslint/no-require-imports -- Load real server actions with isolated request and mail boundaries. */
const { test, before, after, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
const { PrismaClient } = require('@prisma/client');
const { randomUUID } = require('node:crypto');
let prisma, current, fixture, save, generate, publish, assign, removeChild, updateFamily, days;
const tenants = [];
function loader(overrides = {}) {
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = { exports: {} }; cache.set(file, mod);
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
fileName: file,
}).outputText;
const req = spec => {
if (Object.hasOwn(overrides, spec)) return overrides[spec];
if (spec === 'server-only') return {};
if (spec === '@/lib/prisma') return { prisma };
if (spec === 'next/headers') return { headers: async () => new Headers() };
if (spec === 'next/cache') return { revalidatePath() {} };
if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } };
if (spec.startsWith('@/') || spec.startsWith('.')) {
const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec);
for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix);
}
return require(spec);
};
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports);
return mod.exports;
}
return load;
}
const date = key => new Date(`${key}T00:00:00Z`);
const month = { targetYear: 2030, targetMonth: 11, isLocked: false };
const mail = { getAppEmailConfigError: () => null, sendAppEmail: async () => ({ success: true }) };
before(async () => {
const raw = process.env.TEST_DATABASE_URL;
if (!raw) throw new Error('TEST_DATABASE_URL muss auf eine separate lokale Notdienst-Testdatenbank zeigen.');
const url = new URL(raw);
assert.ok(['127.0.0.1', 'localhost'].includes(url.hostname));
assert.equal(url.pathname, '/kita_notdienst_test');
prisma = new PrismaClient({ datasourceUrl: raw });
const originalDates = loader()('src/lib/date-utils.ts');
const load = loader({
'@/lib/auth-utils': {
requireKitaSession: async () => ({ user: current }),
requireRole: async roles => { assert.ok(roles.includes(current.role)); return { user: current }; },
},
'@/lib/alarms/auth': { requireAlarmActor: async () => ({ id: current.id, kitaId: current.kitaId, role: current.role }) },
'@/lib/date-utils': { ...originalDates, getTargetMonthData: () => month },
'@/lib/mail': mail,
});
({ saveNotdienstAvailabilities: save } = load('src/app/dashboard/notdienst/actions.ts'));
({ generatePlanAction: generate, publishPlanAction: publish, updateAssignmentAction: assign } = load('src/app/dashboard/notdienst/plan/actions.ts'));
({ deleteMyChild: removeChild } = load('src/app/dashboard/profil/actions.ts'));
({ updateFamilyAction: updateFamily } = load('src/app/dashboard/families/actions.ts'));
({ loadNotdienstDays: days } = load('src/lib/notdienst-planning.ts'));
});
beforeEach(async () => {
const id = randomUUID();
const kita = await prisma.kita.create({ data: { name: 'Notdienst-Test', slug: `notdienst-${id}`, notdienstMinPerChildPerMonth: 3 } });
tenants.push(kita.id);
const family = await prisma.family.create({ data: { kitaId: kita.id, name: 'Familie Test' } });
const parent = await prisma.user.create({ data: { kitaId: kita.id, familyId: family.id, email: `${id}@example.invalid`, firstName: 'Test', lastName: 'Eltern', passwordHash: 'test', emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
const children = await Promise.all(['A','B'].map(firstName => prisma.child.create({ data: { kitaId: kita.id, familyId: family.id, firstName, lastName: 'Test' } })));
fixture = { kita, family, parent, children };
current = parent;
});
after(async () => {
if (!prisma) return;
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
await prisma.$disconnect();
});
const payload = (dates = ['2030-11-04','2030-11-05','2030-11-06','2030-11-07','2030-11-08','2030-11-11']) => ({ childrenIds: fixture.children.map(c => c.id), dates });
async function admin() { current = await prisma.user.update({ where: { id: fixture.parent.id }, data: { role: 'ADMIN' } }); }
async function closure(start, end = start, status = 'CONFIRMED', kitaId = fixture.kita.id) {
return prisma.termin.create({ data: { kitaId, title: 'Geschlossen', type: 'SCHLIESSTAG', status, startDate: new Date(start), endDate: new Date(end), allDay: true } });
}
async function makePlan(key = '2030-11-04', status = 'DRAFT', childId = fixture.children[0].id) {
return prisma.notdienstPlan.create({ data: { kitaId: fixture.kita.id, year: 2030, month: 11, status, assignments: { create: { kitaId: fixture.kita.id, childId, date: date(key) } } }, include: { assignments: true } });
}
async function seedAvailability(childId, keys) {
return prisma.notdienstAvailability.createMany({ data: keys.map(key => ({ kitaId: fixture.kita.id, childId, userId: fixture.parent.id, date: date(key) })) });
}
test('Vollständiger Haushalt speichert sechs verschiedene Tage für zwei Kinder', async () => {
assert.equal((await save(payload())).success, true);
const entries = await prisma.notdienstAvailability.findMany({ where: { kitaId: fixture.kita.id } });
assert.equal(entries.length, 6);
for (const child of fixture.children) assert.equal(entries.filter(e => e.childId === child.id).length, 3);
});
test('Eine reduzierte Kindermenge kann das Soll nicht umgehen', async () => {
assert.match((await save({ childrenIds: [fixture.children[0].id], dates: payload().dates.slice(0,3) })).error, /alle aktiven Kinder/);
assert.equal(await prisma.notdienstAvailability.count({ where: { kitaId: fixture.kita.id } }), 0);
});
test('Fünf Tage reichen bei zwei Kindern und Soll drei nicht', async () => {
assert.match((await save(payload(payload().dates.slice(0,5)))).error, /mindestens 6/);
});
test('Doppelte Datumsangaben zählen nur einmal', async () => {
assert.match((await save(payload(Array(6).fill('2030-11-04')))).error, /mindestens 6/);
});
test('Doppelte Kinder-IDs werden abgewiesen', async () => {
assert.ok((await save({ ...payload(), childrenIds: Array(2).fill(fixture.children[0].id) })).error);
});
test('Inaktive Kinder können das aktive Kinderset nicht ersetzen', async () => {
const inactive = await prisma.child.create({ data: { kitaId: fixture.kita.id, familyId: fixture.family.id, firstName: 'Inaktiv', lastName: 'Test', active: false } });
assert.ok((await save({ ...payload(), childrenIds: [fixture.children[0].id, inactive.id] })).error);
assert.equal((await save(payload())).success, true);
});
test('Kinder eines anderen Haushalts sind nicht wählbar', async () => {
const family = await prisma.family.create({ data: { kitaId: fixture.kita.id, name: 'Andere Familie' } });
const foreign = await prisma.child.create({ data: { kitaId: fixture.kita.id, familyId: family.id, firstName: 'Fremd', lastName: 'Test' } });
assert.ok((await save({ ...payload(), childrenIds: [fixture.children[0].id, foreign.id] })).error);
});
for (const [label, key] of [['Samstag','2030-11-02'], ['Sonntag','2030-11-03'], ['falscher Monat','2030-12-02']]) {
test(`${label} wird serverseitig abgewiesen und erhält vorhandene Eingaben`, async () => {
assert.equal((await save(payload())).success, true);
assert.ok((await save(payload([key, ...payload().dates.slice(1)]))).error);
assert.equal(await prisma.notdienstAvailability.count({ where: { kitaId: fixture.kita.id } }), 6);
});
}
test('Schließtag wird auch bei einer Uhrzeit nach Mitternacht abgewiesen', async () => {
await closure('2030-11-04T08:00:00Z','2030-11-04T17:00:00Z');
assert.match((await save(payload())).error, /Schließtage/);
});
test('Monatsübergreifende Schließtage einschließlich Berliner Mitternacht', async () => {
await closure('2030-10-31T23:00:00Z','2030-11-04T22:59:00Z');
const allowed = (await days(prisma, fixture.kita.id, 2030, 11)).map(d => d.toISOString().slice(0,10));
assert.ok(!allowed.includes('2030-11-01'));
assert.ok(!allowed.includes('2030-11-04'));
assert.ok(allowed.includes('2030-11-05'));
});
test('Ausstehende und abgelehnte Schließtermine blockieren keine Eingaben', async () => {
await closure('2030-11-04','2030-11-05','PENDING');
await closure('2030-11-06','2030-11-07','REJECTED');
assert.equal((await save(payload())).success, true);
});
test('Schließtage einer anderen Kita beeinflussen die Planung nicht', async () => {
const other = await prisma.kita.create({ data: { name: 'Andere Kita', slug: randomUUID() } }); tenants.push(other.id);
await closure('2030-11-04','2030-11-11','CONFIRMED',other.id);
assert.equal((await save(payload())).success, true);
});
test('Generator ignoriert alte Verfügbarkeiten an Schließtagen und für inaktive Kinder', async () => {
await seedAvailability(fixture.children[0].id, ['2030-11-01','2030-11-02','2030-11-04','2030-11-05']);
await seedAvailability(fixture.children[1].id, ['2030-11-06']);
await prisma.child.update({ where: { id: fixture.children[1].id }, data: { active: false } });
await closure('2030-10-31T23:00:00Z','2030-11-04T22:59:00Z');
await admin();
assert.equal((await generate(2030,11)).success, true);
const assignments = await prisma.notdienstAssignment.findMany({ where: { kitaId: fixture.kita.id } });
assert.deepEqual(assignments.map(a => a.date.toISOString().slice(0,10)), ['2030-11-05']);
});
for (const key of ['2030-11-02','2030-12-02','2030-11-04']) {
test(`Manuelle Einteilung an unzulässigem Tag ${key} wird verhindert`, async () => {
await admin(); const plan = await makePlan('2030-11-05');
await closure('2030-11-04');
assert.ok((await assign(plan.id, key, fixture.children[0].id)).error);
assert.equal(await prisma.notdienstAssignment.count({ where: { planId: plan.id } }),1);
});
}
test('Alte ungültige Einteilungen bleiben entfernbar und können danach veröffentlicht werden', async () => {
await admin(); const plan = await makePlan('2030-12-02');
assert.ok((await publish(plan.id)).error);
assert.equal((await assign(plan.id,'2030-12-02',null)).success,true);
assert.equal((await publish(plan.id)).success,true);
});
test('Neu angelegte Schließtage verhindern die Veröffentlichung alter Entwürfe', async () => {
await admin(); const plan = await makePlan(); await closure('2030-11-04');
assert.match((await publish(plan.id)).error,/geschlossenen Tagen/);
assert.equal((await prisma.notdienstPlan.findUniqueOrThrow({ where: { id: plan.id } })).status,'DRAFT');
assert.equal((await assign(plan.id,'2030-11-04',null)).success,true);
assert.equal((await publish(plan.id)).success,true);
});
test('Inaktive Kinder verhindern die Veröffentlichung eines Entwurfs', async () => {
await admin(); const plan = await makePlan();
await prisma.child.update({ where: { id: fixture.children[0].id }, data: { active: false } });
assert.ok((await publish(plan.id)).error);
});
test('Gültigen Plan veröffentlichen ist wiederholbar und erhält publishedAt', async () => {
await admin(); const plan = await makePlan();
assert.equal((await publish(plan.id)).success,true);
const first = await prisma.notdienstPlan.findUniqueOrThrow({ where: { id: plan.id } });
assert.equal((await publish(plan.id)).success,true);
assert.equal((await prisma.notdienstPlan.findUniqueOrThrow({ where: { id: plan.id } })).publishedAt.getTime(),first.publishedAt.getTime());
});
test('Fremde Pläne sind auch bei manuell manipulierter ID nicht veröffentlichbar', async () => {
const plan = await makePlan(); const old = current;
const other = await prisma.kita.create({ data: { name: 'Andere Kita', slug: randomUUID() } }); tenants.push(other.id);
current = { ...old, kitaId: other.id, role: 'ADMIN' };
assert.ok((await publish(plan.id)).error);
assert.equal((await prisma.notdienstPlan.findUniqueOrThrow({ where: { id: plan.id } })).status,'DRAFT');
});
test('Eltern können ein Kind mit veröffentlichtem zukünftigen Dienst nicht löschen', async () => {
const plan = await makePlan('2030-11-04','PUBLISHED');
assert.match((await removeChild(fixture.children[0].id)).error,/Koordination/);
assert.equal(await prisma.notdienstAssignment.count({ where: { planId: plan.id } }),1);
assert.equal(await prisma.child.count({ where: { id: fixture.children[0].id } }),1);
});
test('Kind ohne veröffentlichte Verpflichtung bleibt löschbar', async () => {
await makePlan('2030-11-04','DRAFT');
assert.equal((await removeChild(fixture.children[0].id)).success,true);
assert.equal(await prisma.child.count({ where: { id: fixture.children[0].id } }),0);
});
test('Vergangene veröffentlichte Dienste blockieren eine Löschung nicht', async () => {
await makePlan('2020-11-04','PUBLISHED');
assert.equal((await removeChild(fixture.children[0].id)).success,true);
});
test('Löschung fremder Kinder bleibt untersagt', async () => {
current = { ...current, familyId: 'foreign-family' };
assert.ok((await removeChild(fixture.children[0].id)).error);
assert.equal(await prisma.child.count({ where: { id: fixture.children[0].id } }),1);
});
test('Admin-Familienbearbeitung kann die Löschsperre nicht umgehen; alles wird zurückgerollt', async () => {
await makePlan('2030-11-04','PUBLISHED'); await admin();
const result = await updateFamily({
familyId: fixture.family.id, familyName: 'Darf nicht gespeichert werden',
parents: [{ id: current.id, firstName: current.firstName, lastName: current.lastName, email: current.email }],
children: [{ id: fixture.children[1].id, firstName: 'Geändert', lastName: 'Test' }],
removedChildIds: [fixture.children[0].id],
});
assert.match(result.error,/Koordination/);
assert.equal((await prisma.family.findUniqueOrThrow({ where: { id: fixture.family.id } })).name,'Familie Test');
assert.equal((await prisma.child.findUniqueOrThrow({ where: { id: fixture.children[1].id } })).firstName,'B');
});
test('Nach manueller Neuzuordnung ist das ursprünglich eingeteilte Kind löschbar', async () => {
await admin(); const plan = await makePlan('2030-11-04','PUBLISHED');
assert.equal((await assign(plan.id,'2030-11-04',fixture.children[1].id)).success,true);
assert.equal((await removeChild(fixture.children[0].id)).success,true);
assert.equal((await prisma.notdienstAssignment.findUniqueOrThrow({ where: { id: plan.assignments[0].id } })).childId,fixture.children[1].id);
});
test('Paralleles Veröffentlichen und Löschen lässt keinen veröffentlichten Dienst unbemerkt verschwinden', async () => {
await admin(); const plan = await makePlan();
const results = await Promise.all([publish(plan.id), removeChild(fixture.children[0].id)]);
const stored = await prisma.notdienstPlan.findUniqueOrThrow({ where: { id: plan.id }, include: { assignments: true } });
if (results[1].error) assert.equal(stored.assignments.length,1);
else { assert.equal(stored.assignments.length,0); assert.equal(await prisma.child.count({ where: { id: fixture.children[0].id } }),0); }
assert.ok(results.every(r => r.success || r.error));
});
test('Teamtag mit geschlossener Kita ist ebenfalls kein Notdiensttag', async () => {
const event = await closure('2030-11-04');
await prisma.termin.update({ where: { id: event.id }, data: { type: 'TEAMTAG' } });
assert.match((await save(payload())).error,/Schließtage/);
await seedAvailability(fixture.children[0].id,['2030-11-04','2030-11-05']);
await admin(); assert.equal((await generate(2030,11)).success,true);
assert.equal(await prisma.notdienstAssignment.count({ where: { kitaId: fixture.kita.id, date: date('2030-11-04') } }),0);
});
test('Heutige veröffentlichte Verpflichtung blockiert die Kindlöschung', async () => {
const berlin = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
await makePlan(berlin,'PUBLISHED');
assert.match((await removeChild(fixture.children[0].id)).error,/Koordination/);
});
test('Aktiver Alarm schützt auch ein Kind mit vergangener Einteilung', async () => {
const plan = await makePlan('2020-11-04','PUBLISHED');
const assignment = plan.assignments[0];
await prisma.notdienstAlarm.create({ data: {
kitaId: fixture.kita.id, assignmentId: assignment.id, activeAssignmentId: assignment.id,
familyId: fixture.family.id, familyName: fixture.family.name, childName: 'A Test',
date: assignment.date, expiresAt: new Date(Date.now()+3600000), deadline: new Date(Date.now()+1800000),
} });
assert.match((await removeChild(fixture.children[0].id)).error,/aktive Alarme/);
assert.equal(await prisma.child.count({ where: { id: fixture.children[0].id } }),1);
});
test('Admin kann ein Kind ohne Verpflichtung weiterhin entfernen', async () => {
await admin();
assert.equal((await updateFamily({
familyId: fixture.family.id, familyName: 'Familie geändert',
parents: [{ id: current.id, firstName: current.firstName, lastName: current.lastName, email: current.email }],
children: [{ id: fixture.children[1].id, firstName: 'B', lastName: 'Test' }],
removedChildIds: [fixture.children[0].id],
})).success,true);
assert.equal(await prisma.child.count({ where: { id: fixture.children[0].id } }),0);
});