Files
kita-planer/tests/notdienst-notifications.integration.test.cjs
T

218 lines
18 KiB
JavaScript

/* 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 {};}},'@/lib/announcement-delivery':{deliverDueAnnouncementEmails: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,3);
}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');
});