218 lines
18 KiB
JavaScript
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');
|
|
});
|