/* eslint-disable @typescript-eslint/no-require-imports -- Load real server actions with isolated request and mail transport 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 { randomUUID } = require('node:crypto'); const { PrismaClient } = require('@prisma/client'); const { render } = require('@react-email/render'); let prisma, current, f, service, publish, retry; let failMail = false; const emails = [], 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 now = new Date('2030-10-19T07:00:00Z'); const sender = async input => { emails.push(input); return failMail ? { success: false, error: 'Test mail failure' } : { success: true, id: 'simulated' }; }; before(async () => { const raw=process.env.TEST_DATABASE_URL; if (!raw) throw new Error('TEST_DATABASE_URL muss auf die separate lokale kita_notifications_test zeigen.'); const url=new URL(raw); assert.ok(['127.0.0.1','localhost'].includes(url.hostname)); assert.equal(url.pathname,'/kita_notifications_test'); process.env.AUTH_URL='http://127.0.0.1:3014'; 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:sender} }); service=load('src/lib/notdienst-notifications.ts'); ({publishPlanAction:publish,retryNotdienstEmailsAction:retry}=load('src/app/dashboard/notdienst/plan/actions.ts')); }); after(async()=>{if(prisma){await prisma.kita.deleteMany({where:{id:{in:tenants}}});await prisma.$disconnect();}}); beforeEach(async()=>{ failMail=false;emails.length=0; const kita=await prisma.kita.create({data:{name:'Benachrichtigungen Test',slug:randomUUID(),notdienstMinPerChildPerMonth:3,notdienstReminderDaysBefore:7}});tenants.push(kita.id); const family=await prisma.family.create({data:{kitaId:kita.id,name:'Testhaushalt'}}); const parent=await prisma.user.create({data:{kitaId:kita.id,familyId:family.id,role:'ADMIN',email:`${randomUUID()}@example.invalid`,firstName:'Test',lastName:'Admin',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'}}))); f={kita,family,parent,children};current=parent; }); const jobs=()=>prisma.notdienstEmail.findMany({where:{kitaId:f.kita.id},orderBy:{createdAt:'asc'}}); const manual=(time=now)=>service.queueNotdienstReminders(f.kita.id,2030,11,false,time); const auto=(time=now)=>service.runNotdienstNotificationJob(sender,time,f.kita.id); async function availability(keys,child=f.children[0]){await prisma.notdienstAvailability.createMany({data:keys.map(key=>({kitaId:f.kita.id,childId:child.id,userId:f.parent.id,date:new Date(key)})),skipDuplicates:true});} async function draft(){return prisma.notdienstPlan.create({data:{kitaId:f.kita.id,year:2030,month:11,assignments:{create:[{kitaId:f.kita.id,childId:f.children[0].id,date:new Date('2030-11-04')},{kitaId:f.kita.id,childId:f.children[1].id,date:new Date('2030-11-05')}]}}});} async function partner(role='KOORDINATOR'){return prisma.user.create({data:{kitaId:f.kita.id,familyId:f.family.id,role,email:`${randomUUID()}@example.invalid`,firstName:'Partner',lastName:role,passwordHash:'test',emailVerifiedAt:new Date(),privacyPolicyAcceptedAt:new Date()}});} test('Automatik erinnert den fehlenden Haushalt genau einmal im Monat',async()=>{ const first=await auto();assert.equal(first.queued,1);assert.equal(first.sent,1); assert.equal((await auto()).queued,0);assert.equal((await auto(new Date('2030-10-20T07:00:00Z'))).queued,0); assert.equal(emails.length,1);assert.equal(await prisma.notdienstReminderRun.count({where:{kitaId:f.kita.id}}),1); }); test('Zu wenige gültige Tage werden als unvollständig erkannt; Mail enthält Soll und Frist',async()=>{ await availability(['2030-11-04','2030-11-05']);assert.equal((await auto()).sent,1); const html=(await render(emails[0].react)).replace(//g,'');assert.ok(html.includes('2 von 6'));assert.ok(html.includes('26.10.2030')); }); test('Vollständige sechs Tage werden nicht erinnert',async()=>{ await availability(['2030-11-04','2030-11-05','2030-11-06','2030-11-07','2030-11-08','2030-11-11']); assert.equal((await auto()).queued,0);assert.equal(emails.length,0); }); test('Geschwister-Doppelbuchungen und alte Wochenenddaten reduzieren das Soll nicht',async()=>{ await availability(['2030-11-04','2030-11-05','2030-11-02']);await availability(['2030-11-04','2030-11-05','2030-11-03'],f.children[1]); const progress=await service.getFamilyNotdienstProgress(prisma,f.kita.id,f.family.id,2030,11,3);assert.deepEqual(progress,{selected:2,required:6}); assert.equal((await auto()).sent,1); }); test('Vor 9 Uhr Berliner Zeit und vor Erinnerungstag wird nichts vorbereitet',async()=>{ assert.equal((await auto(new Date('2030-10-19T06:59:00Z'))).queued,0); assert.equal((await auto(new Date('2030-10-18T07:00:00Z'))).queued,0);assert.equal((await jobs()).length,0); }); test('Verpasster Erinnerungstag wird bis zum 26. nachgeholt',async()=>{assert.equal((await auto(new Date('2030-10-25T07:00:00Z'))).sent,1);}); test('Nach der Sperrfrist werden keine Erinnerungen vorbereitet',async()=>{ assert.equal((await auto(new Date('2030-10-27T09:00:00Z'))).queued,0); await assert.rejects(manual(new Date('2030-10-27T09:00:00Z')),/Eintragsfrist/); }); test('0 schaltet automatische Erinnerungen ab; manueller Versand bleibt möglich',async()=>{ await prisma.kita.update({where:{id:f.kita.id},data:{notdienstReminderDaysBefore:0}}); assert.equal((await auto()).queued,0);assert.equal(await manual(),1); }); test('Deaktiviertes Modul wird weder vorbereitet noch versendet',async()=>{ await manual();await prisma.kita.update({where:{id:f.kita.id},data:{notdienstModuleEnabled:false}}); assert.equal((await auto()).sent,0);assert.equal(emails.length,0);assert.ok((await jobs())[0].skippedAt); }); test('Admin- und Koordinationskonten desselben Haushalts erhalten die Erinnerung',async()=>{ const p=await partner();assert.equal((await auto()).sent,2);assert.deepEqual(emails.map(e=>e.to).sort(),[p.email,f.parent.email].sort()); }); test('Unverifizierte, gesperrte, deaktivierte und Erzieherkonten werden nicht angeschrieben',async()=>{ await partner('ERZIEHER');await prisma.user.update({where:{id:f.parent.id},data:{lockedUntil:new Date('2031-01-01')}}); assert.equal((await auto()).sent,0);assert.equal(emails.length,0); }); test('Manuell und automatisch am selben Tag erzeugen nur einen Auftrag je Konto',async()=>{ assert.equal(await manual(),1);assert.equal(await manual(),0);assert.equal((await auto()).queued,0);assert.equal(emails.length,1); }); test('Parallele automatische Läufe erzeugen nur eine Mailrunde',async()=>{ const results=await Promise.all([auto(),auto()]);assert.equal(results.reduce((n,r)=>n+r.queued,0),1);assert.equal(emails.length,1); }); test('Bei inzwischen vollständiger Auswahl wird ein gespeicherter Reminder übersprungen',async()=>{ await manual();await availability(['2030-11-04','2030-11-05','2030-11-06','2030-11-07','2030-11-08','2030-11-11']); assert.equal((await service.deliverDueNotdienstEmails({kitaId:f.kita.id},sender,now)).skipped,1);assert.equal(emails.length,0); }); test('Haushaltswechsel und fehlende Mailbestätigung machen einen Auftrag ungültig',async()=>{ await manual();await prisma.user.update({where:{id:f.parent.id},data:{emailVerifiedAt:null}}); assert.equal((await service.deliverDueNotdienstEmails({kitaId:f.kita.id},sender,now)).skipped,1);assert.equal(emails.length,0); }); test('Versandfehler bleibt sichtbar; Wiederholung verwendet denselben Idempotenzschlüssel',async()=>{ await manual();failMail=true;const first=await service.deliverDueNotdienstEmails({kitaId:f.kita.id},sender,now);assert.equal(first.failed,1); const failed=(await jobs())[0];assert.equal(failed.failed,true);assert.ok(failed.nextAttemptAt>now);assert.equal(failed.sentAt,null); assert.equal((await service.getNotdienstEmailStatus(f.kita.id,2030,11)).failed,1); assert.equal((await service.deliverDueNotdienstEmails({kitaId:f.kita.id},sender,now)).sent,0); failMail=false;assert.equal((await service.deliverDueNotdienstEmails({kitaId:f.kita.id},sender,failed.nextAttemptAt)).sent,1); assert.equal(emails[0].idempotencyKey,emails[1].idempotencyKey);assert.equal((await jobs())[0].failed,false); }); test('Paralleler Versand reserviert einen Auftrag nur einmal',async()=>{ await manual();const id=(await jobs())[0].id;let count=0; const slow=async()=>{count++;await new Promise(r=>setTimeout(r,20));return {success:true};}; await Promise.all([service.deliverNotdienstEmails([id],slow,now),service.deliverNotdienstEmails([id],slow,now)]);assert.equal(count,1); }); test('Abgelaufene Reservierung nach Worker-Abbruch wird wieder aufgenommen',async()=>{ await manual();const id=(await jobs())[0].id;await prisma.notdienstEmail.update({where:{id},data:{leaseUntil:new Date(now.getTime()-1000),claimToken:'old'}}); assert.equal((await service.deliverDueNotdienstEmails({kitaId:f.kita.id},sender,now)).sent,1); }); test('Eine fehlerhafte Adresse blockiert die zweite Person nicht',async()=>{ await partner();await manual();const result=await service.deliverDueNotdienstEmails({kitaId:f.kita.id},async input=>{emails.push(input);return input.to===f.parent.email?{success:false,error:'failure'}:{success:true};},now); assert.equal(result.failed,1);assert.equal(result.sent,1); }); test('Veröffentlichung fasst Geschwisterdienste pro Konto in einer Mail zusammen',async()=>{ const p=await partner();const plan=await draft();assert.equal((await publish(plan.id)).success,true);assert.equal(emails.length,2); const html=await render(emails[0].react);assert.ok(html.includes('04.11.2030'));assert.ok(html.includes('05.11.2030')); assert.deepEqual(emails.map(e=>e.to).sort(),[p.email,f.parent.email].sort());assert.equal((await jobs()).filter(j=>j.kind==='PUBLICATION').length,2); }); test('Mehrfaches und paralleles Veröffentlichen erzeugt keine doppelten Mails',async()=>{ const plan=await draft();const result=await Promise.all([publish(plan.id),publish(plan.id)]);assert.ok(result.every(r=>r.success)); assert.equal((await jobs()).length,1);assert.equal(emails.length,1);assert.equal((await publish(plan.id)).success,true);assert.equal(emails.length,1); }); test('Mailfehler macht einen veröffentlichten Plan nicht rückgängig',async()=>{ const plan=await draft();failMail=true;const result=await publish(plan.id);assert.equal(result.success,true);assert.equal(result.failed,1); assert.equal((await prisma.notdienstPlan.findUniqueOrThrow({where:{id:plan.id}})).status,'PUBLISHED');assert.equal((await jobs())[0].failed,true); failMail=false;assert.equal((await retry(2030,11)).sent,1);assert.equal((await jobs())[0].sentAt!==null,true); }); test('Beim Speichern der Mailaufträge fehlgeschlagene Veröffentlichung bleibt Entwurf',async()=>{ const plan=await draft();const load=loader({'@/lib/auth-utils':{requireRole:async()=>({user:current})},'@/lib/notdienst-notifications':{queueNotdienstPublication:async()=>{throw new Error('Test storage failure');}}}); const action=load('src/app/dashboard/notdienst/plan/actions.ts').publishPlanAction;assert.ok((await action(plan.id)).error); assert.equal((await prisma.notdienstPlan.findUniqueOrThrow({where:{id:plan.id}})).status,'DRAFT'); }); test('Veröffentlichter Plan verhindert weitere automatische Erinnerungen',async()=>{ const plan=await draft();await publish(plan.id);emails.length=0;assert.equal((await auto()).queued,0);assert.equal(emails.length,0); }); test('Einteilung wird vor einem Wiederholungsversuch erneut geprüft',async()=>{ const plan=await draft();failMail=true;await publish(plan.id);const job=(await jobs())[0]; await prisma.notdienstAssignment.deleteMany({where:{planId:plan.id}});failMail=false; assert.equal((await service.deliverNotdienstEmails([job.id],sender,job.nextAttemptAt)).skipped,1);assert.equal(emails.length,1); }); test('Empfänger sehen bei Wiederholung die aktuelle eigene Einteilung',async()=>{ const plan=await draft();failMail=true;await publish(plan.id);const job=(await jobs())[0]; await prisma.notdienstAssignment.deleteMany({where:{planId:plan.id,date:new Date('2030-11-04')}});failMail=false; await service.deliverNotdienstEmails([job.id],sender,job.nextAttemptAt);const html=await render(emails[1].react); assert.ok(!html.includes('04.11.2030'));assert.ok(html.includes('05.11.2030')); }); test('Kein Auftrag für Haushalte ohne eigene Einteilung',async()=>{ const plan=await prisma.notdienstPlan.create({data:{kitaId:f.kita.id,year:2030,month:11}});await publish(plan.id);assert.equal((await jobs()).length,0); }); test('Datenbank verhindert Empfängerbeziehungen zu einer fremden Kita',async()=>{ const other=await prisma.kita.create({data:{name:'Andere Kita',slug:randomUUID()}});tenants.push(other.id); await assert.rejects(prisma.notdienstEmail.create({data:{kitaId:other.id,familyId:f.family.id,userId:f.parent.id,year:2030,month:11,kind:'REMINDER',eventKey:'cross-tenant'}})); }); test('Wiederholungsaktion begrenzt den Versand auf die eigene Kita',async()=>{ const own=await draft();failMail=true;await publish(own.id);const other=await prisma.kita.create({data:{name:'Andere Kita',slug:randomUUID()}});tenants.push(other.id); const beforeCount=emails.length;current={...current,kitaId:other.id};failMail=false;assert.equal((await retry(2030,11)).sent,0);assert.equal(emails.length,beforeCount); }); test('Monatswechsel und Jahreswechsel verwenden Berliner Kalenderdaten',async()=>{ const dateUtils=loader()('src/lib/date-utils.ts');const target=dateUtils.getTargetMonthData(new Date('2030-12-31T23:00:00Z')); assert.equal(target.targetYear,2031);assert.equal(target.targetMonth,2); assert.equal(dateUtils.isNotdienstReminderDue(7,new Date('2030-10-26T22:00:00Z')),false); }); test('Noch nicht fällige Kitas verdrängen fällige Erinnerungen nicht aus dem Batch',async()=>{ const rows=Array.from({length:101},(_,i)=>({id:`000-not-due-${randomUUID()}-${i}`,name:'Noch nicht fällig',slug:randomUUID(),notdienstReminderDaysBefore:1})); await prisma.kita.createMany({data:rows});tenants.push(...rows.map(r=>r.id)); await service.runNotdienstNotificationJob(sender,now); assert.equal(await prisma.notdienstReminderRun.count({where:{kitaId:f.kita.id,year:2030,month:11}}),1); assert.equal((await jobs()).filter(j=>j.kind==='REMINDER').length,1); }); test('Geschützter Job startet ohne gültiges Secret keine Verarbeitung',async()=>{ const authorization=loader()('src/lib/alarms/job.ts').authorizeAlarmJob;let called=0; const route=loader({'@/lib/alarms/job':{authorizeAlarmJob:authorization,runAlarmJob:async()=>{called++;return {};}},'@/lib/notdienst-notifications':{runNotdienstNotificationJob:async()=>{called++;return {};}}})('src/app/api/jobs/notdienstalarme/route.ts').POST; const old=process.env.NOTDIENST_JOB_SECRET; try{delete process.env.NOTDIENST_JOB_SECRET;assert.equal((await route(new Request('http://127.0.0.1/api/jobs/notdienstalarme',{method:'POST'}))).status,503); process.env.NOTDIENST_JOB_SECRET='test-only-secret-32-characters-long';assert.equal((await route(new Request('http://127.0.0.1/api/jobs/notdienstalarme',{method:'POST',headers:{authorization:'Bearer wrong'}}))).status,401);assert.equal(called,0); const result=await route(new Request('http://127.0.0.1/api/jobs/notdienstalarme',{method:'POST',headers:{authorization:`Bearer ${process.env.NOTDIENST_JOB_SECRET}`}}));assert.equal(result.status,200);assert.equal(called,2); }finally{if(old===undefined)delete process.env.NOTDIENST_JOB_SECRET;else process.env.NOTDIENST_JOB_SECRET=old;} }); test('Zusammengesetzte Mail-Fremdschlüssel schützen auch Nutzer und Plan separat',async()=>{ const other=await prisma.kita.create({data:{name:'Fremde Kita',slug:randomUUID()}});tenants.push(other.id); const foreignUser=await prisma.user.create({data:{kitaId:other.id,email:`${randomUUID()}@example.invalid`,firstName:'Foreign',lastName:'Test',passwordHash:'test'}}); const foreignPlan=await prisma.notdienstPlan.create({data:{kitaId:other.id,year:2030,month:11}}); const base={kitaId:f.kita.id,familyId:f.family.id,userId:f.parent.id,year:2030,month:11,kind:'REMINDER',eventKey:'tenant-check'}; await assert.rejects(prisma.notdienstEmail.create({data:{...base,userId:foreignUser.id}}),e=>e.code==='P2003'); await assert.rejects(prisma.notdienstEmail.create({data:{...base,kind:'PUBLICATION',planId:foreignPlan.id}}),e=>e.code==='P2003'); });