/* 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); });