/* eslint-disable @typescript-eslint/no-require-imports -- Test real actions with isolated request and mail boundaries. */ const { test, before, beforeEach, after } = 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, edit, swap, accept; const tenants = [], emails = []; 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; } before(async () => { const raw = process.env.TEST_DATABASE_URL; if (!raw) throw new Error('TEST_DATABASE_URL muss ausdrücklich auf die lokale kita_quickwins_test zeigen.'); const url = new URL(raw); assert.ok(['127.0.0.1','localhost'].includes(url.hostname)); assert.equal(url.pathname,'/kita_quickwins_test'); prisma = new PrismaClient({ datasourceUrl: raw }); const load = loader({ '@/lib/auth-utils': { requireKitaSession: async () => ({ user: current }), requireRole: async roles => { assert.ok(roles.includes(current.role)); return { user: current }; }, }, '@/lib/mail': { sendAppEmail: async input => { emails.push(input); return { success: true }; } }, }); ({ updateMitbringsel: edit, createNotdienstSwapRequest: swap, acceptNotdienstSwapRequest: accept } = load('src/app/dashboard/kalender/actions.ts')); }); after(async () => { if (!prisma) return; await prisma.kita.deleteMany({ where: { id: { in: tenants } } }); await prisma.$disconnect(); }); beforeEach(async () => { emails.length = 0; const kita = await prisma.kita.create({ data: { name: 'Kalender-Test', slug: `calendar-test-${randomUUID()}` } }); tenants.push(kita.id); const family = await prisma.family.create({ data: { kitaId: kita.id, name: 'Eigener Haushalt' } }); const targetFamily = await prisma.family.create({ data: { kitaId: kita.id, name: 'Angefragter Haushalt' } }); const makeUser = (role, familyId) => prisma.user.create({ data: { kitaId: kita.id, familyId, role, email: `${randomUUID()}@example.invalid`, firstName: 'Test', lastName: role, passwordHash: 'test', emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } }); const owner = await makeUser('ELTERN',family.id); const targetAdmin = await makeUser('ADMIN',targetFamily.id); const targetCoord = await makeUser('KOORDINATOR',targetFamily.id); const event = await prisma.termin.create({ data: { kitaId: kita.id, title: 'Sommerfest', type: 'KITA_FEST', status: 'CONFIRMED', mitbringselListEnabled: true, startDate: new Date('2030-11-04T10:00:00Z'), endDate: new Date('2030-11-04T16:00:00Z') } }); const item = await prisma.mitbringselItem.create({ data: { kitaId: kita.id, terminId: event.id, userId: owner.id, content: 'Nudelsalat' } }); const child = await prisma.child.create({ data: { kitaId: kita.id, familyId: family.id, firstName: 'Eigenes Kind', lastName: 'Test' } }); const targetChild = await prisma.child.create({ data: { kitaId: kita.id, familyId: targetFamily.id, firstName: 'Anderes Kind', lastName: 'Test' } }); const plan = await prisma.notdienstPlan.create({ data: { kitaId: kita.id, year: 2030, month: 11, status: 'PUBLISHED' } }); const offered = await prisma.notdienstAssignment.create({ data: { kitaId: kita.id, planId: plan.id, childId: child.id, date: new Date('2030-11-04T00:00:00Z') } }); const target = await prisma.notdienstAssignment.create({ data: { kitaId: kita.id, planId: plan.id, childId: targetChild.id, date: new Date('2030-11-05T00:00:00Z') } }); fixture = { kita, family, targetFamily, owner, targetAdmin, targetCoord, event, item, child, targetChild, plan, offered, target }; current = owner; }); const edited = content => edit({ itemId: fixture.item.id, content }); const swapPayload = () => ({ targetAssignmentId: fixture.target.id, offeredAssignmentId: fixture.offered.id, message: 'Kannst du tauschen?' }); const storedItem = () => prisma.mitbringselItem.findUniqueOrThrow({ where: { id: fixture.item.id } }); async function setRole(role) { current = await prisma.user.update({ where: { id: fixture.owner.id }, data: { role } }); } test('Eigener Beitrag wird direkt bearbeitet und behält Autor, Termin und ID', async () => { assert.equal((await edited(' Kartoffelsalat ')).success,true); const item = await storedItem(); assert.equal(item.content,'Kartoffelsalat'); assert.equal(item.userId,fixture.owner.id); assert.equal(item.terminId,fixture.event.id); assert.equal(await prisma.mitbringselItem.count({ where: { terminId: fixture.event.id } }),1); }); for (const [label,content] of [['leer',''],['Leerzeichen',' '],['zu lang','a'.repeat(501)]]) { test(`Ungültiger Beitrag (${label}) erhält den alten Inhalt`, async () => { assert.ok((await edited(content)).error); assert.equal((await storedItem()).content,'Nudelsalat'); }); } test('500 Zeichen werden vollständig gespeichert', async () => { assert.equal((await edited('a'.repeat(500))).success,true); assert.equal((await storedItem()).content.length,500); }); for (const role of ['ELTERN','ADMIN','KOORDINATOR']) { test(`${role} kann fremde Mitbringbeiträge nicht bearbeiten`, async () => { current = { ...fixture.targetAdmin, role }; assert.match((await edited('Fremde Änderung')).error,/nur eigene/); assert.equal((await storedItem()).content,'Nudelsalat'); }); } for (const role of ['ADMIN','KOORDINATOR']) { test(`${role} kann einen eigenen Mitbringbeitrag bearbeiten`, async () => { await setRole(role); assert.equal((await edited('Gemüsesalat')).success,true); }); } test('Mitbringbeiträge einer anderen Kita sind nicht bearbeitbar', async () => { const kita = await prisma.kita.create({ data: { name: 'Andere Kita', slug: randomUUID() } }); tenants.push(kita.id); current = { ...fixture.owner, kitaId: kita.id }; assert.ok((await edited('Tenant-Wechsel')).error); assert.equal((await storedItem()).content,'Nudelsalat'); }); for (const status of ['PENDING','REJECTED']) { test(`Ein ${status}-Termin erlaubt keine Bearbeitung`, async () => { await prisma.termin.update({ where: { id: fixture.event.id }, data: { status } }); assert.match((await edited('Brot')).error,/nicht mehr aktiv/); assert.equal((await storedItem()).content,'Nudelsalat'); }); } test('Deaktivierte Mitbringliste schützt vorhandene Beiträge', async () => { await prisma.termin.update({ where: { id: fixture.event.id }, data: { mitbringselListEnabled: false } }); assert.ok((await edited('Brot')).error); assert.equal((await storedItem()).content,'Nudelsalat'); }); test('Gelöschter Beitrag wird nicht neu angelegt', async () => { await prisma.mitbringselItem.delete({ where: { id: fixture.item.id } }); assert.ok((await edited('Brot')).error); assert.equal(await prisma.mitbringselItem.count({ where: { id: fixture.item.id } }),0); }); for (const role of ['ELTERN','ADMIN','KOORDINATOR']) { test(`${role} fragt mit eigenem Haushalt einen Diensttausch an und benachrichtigt Eltern mit Verwaltungsrolle`, async () => { await setRole(role); assert.equal((await swap(swapPayload())).success,true); const request = await prisma.notdienstSwapRequest.findFirstOrThrow({ where: { kitaId: fixture.kita.id } }); assert.equal(request.requesterUserId,current.id); assert.equal(request.requesterFamilyId,fixture.family.id); assert.deepEqual(emails.map(e => e.to).sort(),[fixture.targetAdmin.email,fixture.targetCoord.email].sort()); current = fixture.targetCoord; assert.equal((await accept(request.id)).success,true); assert.equal((await prisma.notdienstAssignment.findUniqueOrThrow({ where: { id: fixture.target.id } })).childId,fixture.child.id); assert.equal((await prisma.notdienstAssignment.findUniqueOrThrow({ where: { id: fixture.offered.id } })).childId,fixture.targetChild.id); assert.ok(emails.some(e => e.to === fixture.owner.email && e.subject === 'Notdienst-Tausch bestätigt')); }); } for (const role of ['ADMIN','KOORDINATOR']) { test(`${role} ohne eigenen Haushalt erhält kein Tauschrecht`, async () => { await setRole(role); current = { ...current, familyId: null }; assert.ok((await swap(swapPayload())).error); assert.equal(await prisma.notdienstSwapRequest.count({ where: { kitaId: fixture.kita.id } }),0); }); } test('Erzieherrolle erhält auch mit einem Haushalt kein Tauschrecht', async () => { await setRole('ERZIEHER'); assert.ok((await swap(swapPayload())).error); }); test('Admin kann keine fremde Einteilung als eigenes Angebot verwenden', async () => { await setRole('ADMIN'); assert.match((await swap({ targetAssignmentId: fixture.offered.id, offeredAssignmentId: fixture.target.id })).error,/nur eigene/); assert.equal(await prisma.notdienstSwapRequest.count({ where: { kitaId: fixture.kita.id } }),0); }); test('Koordination kann keinen Tausch innerhalb des eigenen Haushalts starten', async () => { await setRole('KOORDINATOR'); await prisma.notdienstAssignment.update({ where: { id: fixture.target.id }, data: { childId: fixture.child.id } }); assert.match((await swap(swapPayload())).error,/eigenen Haushalt/); }); test('Aktiver Notdienstalarm blockiert auch einen Admin-Tausch', async () => { await setRole('ADMIN'); await prisma.notdienstAlarm.create({ data: { kitaId: fixture.kita.id, assignmentId: fixture.offered.id, activeAssignmentId: fixture.offered.id, familyId: fixture.family.id, familyName: fixture.family.name, childName: 'Eigenes Kind', date: fixture.offered.date, expiresAt: new Date('2030-11-04T18:00:00Z'), deadline: new Date('2030-11-04T10:00:00Z') } }); assert.match((await swap(swapPayload())).error,/aktiver Alarmierung/); }); test('Unverifizierte und deaktivierte Konten bleiben vom Tausch-Mailversand ausgeschlossen', async () => { await prisma.user.update({ where: { id: fixture.targetAdmin.id }, data: { emailVerifiedAt: null } }); await prisma.user.update({ where: { id: fixture.targetCoord.id }, data: { disabledAt: new Date() } }); await setRole('ADMIN'); assert.equal((await swap(swapPayload())).success,true); assert.equal(emails.length,0); }); test('Doppelte offene Anfrage bleibt auch für Koordination gesperrt', async () => { await setRole('KOORDINATOR'); assert.equal((await swap(swapPayload())).success,true); assert.match((await swap(swapPayload())).error,/offene Tauschanfrage/); assert.equal(await prisma.notdienstSwapRequest.count({ where: { kitaId: fixture.kita.id } }),1); });