feat: complete notdienst planning, quick wins and notifications
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
/* 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);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports -- Execute the installed SDK and real TypeScript mail adapter. */
|
||||
const { test, before, after }=require('node:test');const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),ts=require('typescript');
|
||||
let prisma; // The mail adapter never accesses a database.
|
||||
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 originalFetch=global.fetch;const saved=Object.fromEntries(['RESEND_API_KEY','EMAIL_FROM','NODE_ENV'].map(k=>[k,process.env[k]]));let send;
|
||||
before(()=>{process.env.RESEND_API_KEY='re_test_not_real';process.env.EMAIL_FROM='Test <test@example.invalid>';process.env.NODE_ENV='test';send=loader()('src/lib/mail.ts').sendAppEmail;});
|
||||
after(()=>{global.fetch=originalFetch;for(const [k,v] of Object.entries(saved)){if(v===undefined)delete process.env[k];else process.env[k]=v;}});
|
||||
const input={to:'recipient@example.invalid',subject:'Test',react:'Test',idempotencyKey:'stable-job'};
|
||||
test('SDK receives the stable key and an AbortSignal on the actual fetch call',async()=>{
|
||||
let captured;global.fetch=async(_url,options)=>{captured=options;return new Response(JSON.stringify({id:'test-id'}),{status:200});};
|
||||
assert.equal((await send(input)).success,true);assert.ok(captured.signal instanceof AbortSignal);assert.match(new Headers(captured.headers).get('Idempotency-Key'),/^stable-job\/[a-f0-9]{64}$/);
|
||||
});
|
||||
test('Transport timeout aborts the pending fetch rather than merely abandoning its promise',async()=>{
|
||||
let aborted=false;global.fetch=async(_url,options)=>new Promise((_resolve,reject)=>options.signal.addEventListener('abort',()=>{aborted=true;reject(options.signal.reason);},{once:true}));
|
||||
const keepAlive=setTimeout(()=>{},1000);try{assert.equal((await send({...input,timeoutMs:20})).success,false);assert.equal(aborted,true);}finally{clearTimeout(keepAlive);}
|
||||
});
|
||||
test('Array recipients have distinct stable keys and duplicate addresses are removed',async()=>{
|
||||
const keys=[];global.fetch=async(_url,options)=>{keys.push(new Headers(options.headers).get('Idempotency-Key'));return new Response(JSON.stringify({id:'test'}),{status:200});};
|
||||
const message={...input,to:['one@example.invalid','two@example.invalid','one@example.invalid']};await send(message);await send(message);
|
||||
assert.equal(keys.length,4);assert.notEqual(keys[0],keys[1]);assert.equal(keys[0],keys[2]);assert.equal(keys[1],keys[3]);
|
||||
});
|
||||
|
||||
test('Corrected message payload receives a new version without changing stable retries',async()=>{
|
||||
const keys=[];global.fetch=async(_url,options)=>{keys.push(new Headers(options.headers).get('Idempotency-Key'));return new Response(JSON.stringify({id:'test'}),{status:200});};
|
||||
await send(input);await send(input);await send({...input,react:'Corrected dates'});assert.equal(keys[0],keys[1]);assert.notEqual(keys[1],keys[2]);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getNotdienstDays } from "../src/lib/notdienst-days";
|
||||
|
||||
const keys = (year: number, month: number, start: string, end: string) =>
|
||||
getNotdienstDays(year, month, [
|
||||
{ startDate: new Date(start), endDate: new Date(end) },
|
||||
]).map((d) => d.toISOString().slice(0, 10));
|
||||
|
||||
test("Schließtage schließen inklusive beider Randtage aus", () => {
|
||||
const days = keys(2030, 11, "2030-11-04T10:00:00Z", "2030-11-06T12:00:00Z");
|
||||
for (const key of ["2030-11-04", "2030-11-05", "2030-11-06"])
|
||||
assert.ok(!days.includes(key));
|
||||
assert.ok(days.includes("2030-11-07"));
|
||||
assert.ok(days.every((key) => ![0, 6].includes(new Date(key).getUTCDay())));
|
||||
});
|
||||
|
||||
test("Berliner Mitternacht am Monatsanfang zählt zum richtigen Monat", () => {
|
||||
const days = keys(2030, 11, "2030-10-31T23:00:00Z", "2030-11-01T22:59:00Z");
|
||||
assert.ok(!days.includes("2030-11-01"));
|
||||
assert.ok(days.includes("2030-11-04"));
|
||||
});
|
||||
|
||||
test("Sommerzeit und Schließzeiten über Monatsgrenzen", () => {
|
||||
const days = keys(2030, 7, "2030-06-28T22:00:00Z", "2030-07-02T21:59:00Z");
|
||||
assert.ok(!days.includes("2030-07-01"));
|
||||
assert.ok(!days.includes("2030-07-02"));
|
||||
assert.ok(days.includes("2030-07-03"));
|
||||
});
|
||||
|
||||
test("Überlappende Schließtermine erzeugen keine doppelten Tage", () => {
|
||||
const closures = [
|
||||
{ startDate: new Date("2030-11-04"), endDate: new Date("2030-11-05") },
|
||||
{ startDate: new Date("2030-11-05"), endDate: new Date("2030-11-06") },
|
||||
];
|
||||
const days = getNotdienstDays(2030, 11, closures);
|
||||
assert.equal(new Set(days.map(Number)).size, days.length);
|
||||
assert.equal(days.length, getNotdienstDays(2030, 11, []).length - 3);
|
||||
});
|
||||
|
||||
|
||||
test("Berliner Monatswechsel bestimmt Zielmonat und Sperrfrist", async () => {
|
||||
const { getTargetMonthData } = await import("../src/lib/date-utils");
|
||||
assert.equal(getTargetMonthData(new Date("2030-10-26T22:00:00Z")).isLocked, true);
|
||||
const data = getTargetMonthData(new Date("2030-12-31T23:00:00Z"));
|
||||
assert.equal(data.targetYear, 2031); assert.equal(data.targetMonth, 2);
|
||||
});
|
||||
|
||||
test("Automatische Erinnerung beginnt um 9 Uhr vor der Eintragsfrist", async () => {
|
||||
const { isNotdienstReminderDue } = await import("../src/lib/date-utils");
|
||||
assert.equal(isNotdienstReminderDue(7, new Date("2030-10-18T07:00:00Z")), false);
|
||||
assert.equal(isNotdienstReminderDue(7, new Date("2030-10-19T06:59:00Z")), false);
|
||||
assert.equal(isNotdienstReminderDue(7, new Date("2030-10-19T07:00:00Z")), true);
|
||||
assert.equal(isNotdienstReminderDue(0, new Date("2030-10-19T07:00:00Z")), false);
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/* 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');
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
/* 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);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports -- CommonJS fixtures exercise the native Node process supervisor. */
|
||||
const {test}=require('node:test');const assert=require('node:assert/strict');const {spawn}=require('node:child_process');const {mkdtempSync,mkdirSync,writeFileSync,rmSync}=require('node:fs');const {tmpdir}=require('node:os');const {join,resolve}=require('node:path');
|
||||
function fixture(server,worker){const root=mkdtempSync(join(tmpdir(),'kita-runtime-test-'));mkdirSync(join(root,'scripts'));writeFileSync(join(root,'server.js'),server);writeFileSync(join(root,'scripts/notdienst-alarm-worker.mjs'),worker);return root;}
|
||||
function waitForOutput(child,marker){return new Promise((resolvePromise,reject)=>{let text='';const timer=setTimeout(()=>reject(new Error(`Missing ${marker}`)),10000);child.stdout.on('data',chunk=>{text+=chunk;if(text.includes(marker)){clearTimeout(timer);resolvePromise(text);}});});}
|
||||
function exited(child){return new Promise(resolvePromise=>child.once('exit',(code,signal)=>resolvePromise({code,signal})));}
|
||||
function gone(pid){try{process.kill(pid,0);return false;}catch{return true;}}
|
||||
test('Worker crash is restarted while the server stays alive; shutdown stops both',async()=>{
|
||||
const root=fixture('console.log(`server:${process.pid}`);setInterval(()=>{},1000);',`import {existsSync,writeFileSync} from 'node:fs';if(!existsSync('once')){writeFileSync('once','1');console.log('worker-crashed');process.exit(1);}console.log(\`worker-ready:\${process.pid}\`);setInterval(()=>{},1000);`);
|
||||
const child=spawn(process.execPath,[resolve('scripts/start-runtime.mjs')],{cwd:root,env:{...process.env,NOTDIENST_JOB_SECRET:'test-only-secret-32-characters-long'},stdio:['ignore','pipe','pipe']});
|
||||
try{const text=await waitForOutput(child,'worker-ready:');const serverPid=Number(text.match(/server:(\d+)/)[1]);const workerPid=Number(text.match(/worker-ready:(\d+)/)[1]);assert.equal(gone(serverPid),false);const ended=exited(child);child.kill('SIGTERM');assert.equal((await ended).code,0);assert.equal(gone(serverPid),true);assert.equal(gone(workerPid),true);}finally{if(child.exitCode===null)child.kill('SIGTERM');rmSync(root,{recursive:true,force:true});}
|
||||
});
|
||||
test('Server failure shuts down the worker and preserves the failure exit code',async()=>{
|
||||
const root=fixture('setTimeout(()=>process.exit(7),150);', 'console.log(`worker:${process.pid}`);setInterval(()=>{},1000);');
|
||||
const child=spawn(process.execPath,[resolve('scripts/start-runtime.mjs')],{cwd:root,env:{...process.env,NOTDIENST_JOB_SECRET:'test-only-secret-32-characters-long'},stdio:['ignore','pipe','pipe']});
|
||||
try{const ended=exited(child);const text=await waitForOutput(child,'worker:');const pid=Number(text.match(/worker:(\d+)/)[1]);assert.equal((await ended).code,7);assert.equal(gone(pid),true);}finally{if(child.exitCode===null)child.kill('SIGTERM');rmSync(root,{recursive:true,force:true});}
|
||||
});
|
||||
@@ -355,7 +355,7 @@ test('upload quota holds under concurrent writes and accepted MIME comes from fi
|
||||
|
||||
test('all supplemental database tenant constraints are present', async () => {
|
||||
const [row] = await prisma.$queryRaw`SELECT count(*)::int AS count FROM pg_constraint WHERE conname LIKE '%_tenant_fkey'`;
|
||||
assert.equal(row.count,41);
|
||||
assert.equal(row.count,44);
|
||||
});
|
||||
test('role guard authenticates once and keeps role validation on that session', async () => {
|
||||
let calls = 0;
|
||||
|
||||
Reference in New Issue
Block a user