import { after, test } from "node:test"; import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import type { UserRole } from "@prisma/client"; import { render } from "@react-email/render"; import { prisma } from "../src/lib/prisma"; import { executeAlarmCommand, respondToAlarm, getPublicAlarm, getAlarmFeed, processAlarmDeadlines, importLegacyAlarms, assertAssignmentNotAlarmed, type AlarmActor, } from "../src/lib/alarms/service"; import { deliverAlarmEmails } from "../src/lib/alarms/email"; import { authorizeAlarmJob } from "../src/lib/alarms/job"; import { updateAssignmentAction } from "../src/app/dashboard/notdienst/plan/actions"; import { createNotdienstSwapRequest, acceptNotdienstSwapRequest, } from "../src/app/dashboard/kalender/actions"; import { berlinInstant, endOfAlarm, alarmLabel, } from "../src/lib/alarms/format"; import { dateOnly } from "../src/lib/staff/calculations"; const db = new URL(process.env.DATABASE_URL ?? "postgresql://invalid"); if ( db.hostname !== "127.0.0.1" || db.port !== "55439" || db.pathname !== "/kita_staff_test" ) throw new Error( "Alarmtests benötigen die isolierte Datenbank 127.0.0.1:55439/kita_staff_test.", ); process.env.AUTH_URL = "http://127.0.0.1:3008"; const tenants: string[] = []; after(async () => { await prisma.kita.deleteMany({ where: { id: { in: tenants } } }); await prisma.$disconnect(); }); const date = "2090-01-09", now = berlinInstant(date, 480); async function fixture() { const kita = await prisma.kita.create({ data: { name: "Alarm-Testkita", slug: `alarm-test-${randomUUID()}` }, }); tenants.push(kita.id); const family = await prisma.family.create({ data: { kitaId: kita.id, name: "Testfamilie" }, }); const otherFamily = await prisma.family.create({ data: { kitaId: kita.id, name: "Anderer Haushalt" }, }); const makeUser = (role: UserRole, familyId?: string) => prisma.user.create({ data: { kitaId: kita.id, role, familyId, email: `${randomUUID()}@alarm-test.invalid`, firstName: "Test", lastName: role, passwordHash: "test-only", privacyPolicyAcceptedAt: new Date(), }, }); const adminUser = await makeUser("ADMIN"), coordUser = await makeUser("KOORDINATOR"), staffUser = await makeUser("ERZIEHER"), p1 = await makeUser("ELTERN", family.id), p2 = await makeUser("ELTERN", family.id), outsider = await makeUser("ELTERN", otherFamily.id); const actor = (u: typeof p1): AlarmActor => ({ id: u.id, kitaId: kita.id, role: u.role, }); const admin = actor(adminUser), coord = actor(coordUser), staff = actor(staffUser), parent = actor(p1), otherParent = actor(outsider); const child = await prisma.child.create({ data: { kitaId: kita.id, familyId: family.id, firstName: "Testkind", lastName: "Testfamilie", }, }); const educator = await prisma.educator.create({ data: { kitaId: kita.id, firstName: "Interne", lastName: "Fachkraft" }, }); const plan = await prisma.notdienstPlan.create({ data: { kitaId: kita.id, year: 2090, month: 1, status: "PUBLISHED", publishedAt: now, createdById: admin.id, }, }); const assignment = await prisma.notdienstAssignment.create({ data: { kitaId: kita.id, planId: plan.id, childId: child.id, date: dateOnly(date), }, }); const create = ( data: Record = {}, who = admin, time = now, ) => executeAlarmCommand( who, "create", { assignmentId: assignment.id, wholeDay: true, ...data }, time, ); const command = ( name: string, alarmId: string, data: Record = {}, who = coord, time = now, ) => executeAlarmCommand(who, name, { alarmId, ...data }, time); const recipients = (alarmId: string) => prisma.notdienstAlarmRecipient.findMany({ where: { alarmId }, orderBy: { userId: "asc" }, }); return { kita, family, otherFamily, adminUser, coordUser, admin, coord, staff, parent, otherParent, p1, p2, outsider, child, educator, plan, assignment, create, command, recipients, }; } test("Hinweise können nur vom eigenen Empfänger gelesen werden", async () => { const f = await fixture(); const alarm = await f.create(); const notice = await prisma.notdienstAlarmNotification.findFirstOrThrow({ where: { alarmId: alarm.alarmId, userId: f.p1.id }, }); await executeAlarmCommand( f.otherParent, "readNotice", { id: notice.id }, now, ); assert.equal( ( await prisma.notdienstAlarmNotification.findUniqueOrThrow({ where: { id: notice.id }, }) ).readAt, null, ); await executeAlarmCommand(f.parent, "readNotice", { id: notice.id }, now); assert.equal( ( await prisma.notdienstAlarmNotification.findUniqueOrThrow({ where: { id: notice.id }, }) ).readAt?.getTime(), now.getTime(), ); }); test("Gesicherte Konten und fehlende Einwilligung verhindern Verwaltungszugriff", async () => { const f = await fixture(); await prisma.user.update({ where: { id: f.admin.id }, data: { privacyPolicyAcceptedAt: null }, }); await assert.rejects(f.create(), /keinen Zugriff/); await prisma.user.update({ where: { id: f.admin.id }, data: { privacyPolicyAcceptedAt: new Date(), lockedUntil: new Date(Date.now() + 3600000), }, }); await assert.rejects(f.create(), /keinen Zugriff/); const alarm = await f.create({}, f.coord); const token = (await f.recipients(alarm.alarmId!)).find( (r) => r.userId === f.p1.id, )!.token; await prisma.user.update({ where: { id: f.p1.id }, data: { lockedUntil: new Date(Date.now() + 3600000) }, }); assert.equal(await getPublicAlarm(token), null); await assert.rejects(respondToAlarm(token, true, now), /Ungültiger/); }); test("Aktive Alarme sperren manuelle Zuordnung sowie neue und bereits angefragte Tausche", async () => { const f = await fixture(); const otherChild = await prisma.child.create({ data: { kitaId: f.kita.id, familyId: f.otherFamily.id, firstName: "Anderes", lastName: "Testkind", }, }); const otherAssignment = await prisma.notdienstAssignment.create({ data: { kitaId: f.kita.id, planId: f.plan.id, childId: otherChild.id, date: dateOnly("2090-01-10"), }, }); const request = await prisma.notdienstSwapRequest.create({ data: { kitaId: f.kita.id, requesterUserId: f.outsider.id, requesterFamilyId: f.otherFamily.id, targetAssignmentId: f.assignment.id, offeredAssignmentId: otherAssignment.id, }, }); await f.create(); const names = [ "TEST_MODE", "TEST_USER_ID", "TEST_USER_ROLE", "TEST_KITA_ID", "TEST_FAMILY_ID", ]; const previous = names.map((key) => process.env[key]); function login(actor: AlarmActor, familyId: string) { process.env.TEST_MODE = "true"; process.env.TEST_USER_ID = actor.id; process.env.TEST_USER_ROLE = actor.role; process.env.TEST_KITA_ID = actor.kitaId; process.env.TEST_FAMILY_ID = familyId; } try { login(f.admin, f.family.id); const manual = await updateAssignmentAction(f.plan.id, date, otherChild.id); assert.match(manual.error ?? "", /Alarm/); login(f.otherParent, f.otherFamily.id); const newSwap = await createNotdienstSwapRequest({ targetAssignmentId: f.assignment.id, offeredAssignmentId: otherAssignment.id, }); assert.match(newSwap.error ?? "", /Alarmierung/); login(f.parent, f.family.id); const accept = await acceptNotdienstSwapRequest(request.id); assert.match(accept.error ?? "", /Alarmierung/); assert.equal( ( await prisma.notdienstSwapRequest.findUniqueOrThrow({ where: { id: request.id }, }) ).status, "PENDING", ); assert.equal( ( await prisma.notdienstAssignment.findUniqueOrThrow({ where: { id: f.assignment.id }, }) ).childId, f.child.id, ); assert.equal( ( await prisma.notdienstAssignment.findUniqueOrThrow({ where: { id: otherAssignment.id }, }) ).childId, otherChild.id, ); } finally { names.forEach((key, index) => { if (previous[index] === undefined) delete process.env[key]; else process.env[key] = previous[index]; }); } }); test("Nur aktuelle Admins und Koordination derselben Kita dürfen Alarme verwalten", async () => { const f = await fixture(), foreign = await fixture(); await assert.rejects(f.create({}, f.staff), /Kein Zugriff/); await assert.rejects(f.create({}, f.parent), /Kein Zugriff/); await assert.rejects(f.create({}, foreign.admin), /nicht gefunden/); await assert.rejects( f.create({}, { ...f.admin, kitaId: foreign.kita.id }), /keinen Zugriff/, ); await prisma.user.update({ where: { id: f.admin.id }, data: { role: "ELTERN" }, }); await assert.rejects(f.create(), /keinen Zugriff/); const result = await f.create({}, f.coord); await assert.rejects( f.command("cancel", result.alarmId!, { reason: "Teststorno" }, f.parent), /Kein Zugriff/, ); }); test("Eingeteilter Haushalt erhält zwei persönliche Links, gemeinsame Antwort und 30 Minuten Frist", async () => { const f = await fixture(), result = await f.create(); const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: result.alarmId }, }); assert.equal(alarm.deadline.getTime() - now.getTime(), 30 * 60_000); assert.equal(alarm.wholeDay, true); const recipients = await f.recipients(alarm.id); assert.deepEqual( recipients.map((r) => r.userId).sort(), [f.p1.id, f.p2.id].sort(), ); assert.notEqual(recipients[0].token, recipients[1].token); assert.equal(result.emailIds.length, 2); assert.equal((await getAlarmFeed(f.parent, now)).length, 1); assert.equal((await getAlarmFeed(f.otherParent, now)).length, 0); assert.equal(await getPublicAlarm(randomUUID()), null); await respondToAlarm( recipients[0].token, true, new Date(now.getTime() + 60_000), ); assert.equal( (await getPublicAlarm(recipients[1].token))?.status, "CONFIRMED", ); await assert.rejects( respondToAlarm(recipients[1].token, false, now), /bereits/, ); }); test("Entwürfe, deaktivierte Module, vergangene Einsätze und ungültige Uhrzeiten werden abgewiesen", async () => { const f = await fixture(); await prisma.notdienstPlan.update({ where: { id: f.plan.id }, data: { status: "DRAFT" }, }); await assert.rejects(f.create(), /veröffentlichen/); await prisma.notdienstPlan.update({ where: { id: f.plan.id }, data: { status: "PUBLISHED" }, }); await prisma.kita.update({ where: { id: f.kita.id }, data: { notdienstModuleEnabled: false }, }); await assert.rejects(f.create(), /deaktiviert/); await prisma.kita.update({ where: { id: f.kita.id }, data: { notdienstModuleEnabled: true }, }); await assert.rejects( f.create({ wholeDay: false, startMinute: 900, endMinute: 800 }), /Ende nach Beginn/, ); await assert.rejects( f.create({ wholeDay: false, startMinute: 360, endMinute: 420 }), /beendet/, ); await assert.rejects(f.create({ educatorId: randomUUID() }), /Fachkraft/); await assert.rejects(f.create({ replyMinutes: 0 })); await prisma.notdienstAssignment.update({ where: { id: f.assignment.id }, data: { date: dateOnly("2090-01-08") }, }); await assert.rejects(f.create(), /Vergangene/); }); test("Uhrzeiten gelten in Berlin und eine kurze Einsatzdauer begrenzt die Antwortfrist", async () => { const f = await fixture(); const result = await f.create({ wholeDay: false, startMinute: 480, endMinute: 495, }); const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: result.alarmId }, }); assert.equal( alarm.expiresAt.toISOString(), berlinInstant(date, 495).toISOString(), ); assert.equal(alarm.deadline.toISOString(), alarm.expiresAt.toISOString()); assert.equal(alarm.startMinute, 480); await assert.rejects( respondToAlarm( (await f.recipients(alarm.id))[0].token, true, alarm.expiresAt, ), /abgelaufen/, ); }); test("Zeitumstellung und Tagesende werden unabhängig von der Serverzeitzone berechnet", () => { assert.equal( berlinInstant("2026-10-09", 480).toISOString(), "2026-10-09T06:00:00.000Z", ); assert.equal( berlinInstant("2026-12-09", 480).toISOString(), "2026-12-09T07:00:00.000Z", ); assert.equal( endOfAlarm("2026-03-29", true).toISOString(), "2026-03-29T22:00:00.000Z", ); assert.equal( endOfAlarm("2026-10-25", true).toISOString(), "2026-10-25T23:00:00.000Z", ); assert.throws(() => berlinInstant("2026-03-29", 150), /Zeitumstellung/); }); test("Parallele Auslösung erstellt genau einen aktiven Vorgang", async () => { const f = await fixture(); const results = await Promise.allSettled([f.create(), f.create()]); assert.equal(results.filter((r) => r.status === "fulfilled").length, 1); assert.equal( await prisma.notdienstAlarm.count({ where: { activeAssignmentId: f.assignment.id }, }), 1, ); assert.equal( await prisma.notdienstAlarmRecipient.count({ where: { alarm: { kitaId: f.kita.id } }, }), 2, ); }); test("Gegensätzliche gleichzeitige Haushaltsantworten können nicht beide gespeichert werden", async () => { const f = await fixture(), result = await f.create(); const recipients = await f.recipients(result.alarmId!); const answers = await Promise.allSettled([ respondToAlarm(recipients[0].token, true, now), respondToAlarm(recipients[1].token, false, now), ]); assert.equal(answers.filter((a) => a.status === "fulfilled").length, 1); const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: result.alarmId }, }); assert.ok(["CONFIRMED", "DECLINED"].includes(alarm.status)); assert.equal( await prisma.notdienstAlarmHistory.count({ where: { alarmId: alarm.id, action: { in: ["Zusage des Haushalts", "Absage des Haushalts"] }, }, }), 1, ); assert.equal( await prisma.notdienstAlarmEmail.count({ where: { alarmId: alarm.id, kind: "MANAGER" }, }), 2, ); }); test("Fristprüfung eskaliert ohne Elternantwort genau einmal und erzeugt nur Koordinationshinweise", async () => { const f = await fixture(), result = await f.create(); assert.equal( ( await processAlarmDeadlines( new Date(now.getTime() + 29 * 60_000), f.kita.id, ) ).length, 0, ); const deadline = new Date(now.getTime() + 30 * 60_000); const results = await Promise.all([ processAlarmDeadlines(deadline, f.kita.id), processAlarmDeadlines(deadline, f.kita.id), ]); assert.equal(results.flat().length, 2); const jobs = await prisma.notdienstAlarmEmail.findMany({ where: { alarmId: result.alarmId, eventKey: "deadline" }, }); assert.deepEqual( jobs.map((j) => j.userId).sort(), [f.admin.id, f.coord.id].sort(), ); assert.equal((await processAlarmDeadlines(deadline, f.kita.id)).length, 0); const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: result.alarmId }, }); assert.ok(alarm.escalatedAt); assert.match(alarmLabel(alarm, deadline), /Frist abgelaufen/); }); test("Rechtzeitige Zusagen verhindern Fristmeldungen; Absagen informieren sofort", async () => { const f = await fixture(), result = await f.create(); const token = (await f.recipients(result.alarmId!))[0].token; const response = await respondToAlarm(token, false, now); assert.equal(response.emailIds.length, 2); assert.equal( ( await processAlarmDeadlines( new Date(now.getTime() + 60 * 60_000), f.kita.id, ) ).length, 0, ); assert.equal( ( await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: result.alarmId }, }) ).status, "DECLINED", ); const other = await fixture(), accepted = await other.create(); await respondToAlarm( (await other.recipients(accepted.alarmId!))[0].token, true, now, ); assert.equal( ( await processAlarmDeadlines( new Date(now.getTime() + 60 * 60_000), other.kita.id, ) ).length, 0, ); }); test("Verspätete Zusagen benötigen Freigabe; telefonische Korrekturen bleiben intern begründet", async () => { const f = await fixture(), result = await f.create({ notes: "PRIVATE_KRANKHEITSNOTIZ", educatorId: f.educator.id, }); const late = new Date(now.getTime() + 31 * 60_000), token = (await f.recipients(result.alarmId!))[0].token; await respondToAlarm(token, true, late); assert.equal((await getPublicAlarm(token))?.status, "LATE_CONFIRMATION"); await f.command( "approveLate", result.alarmId!, { reason: "PRIVATE_FREIGABE" }, f.coord, late, ); assert.equal((await getPublicAlarm(token))?.status, "CONFIRMED"); await f.command( "recordResponse", result.alarmId!, { approve: false, reason: "PRIVATE_TELEFONNOTIZ" }, f.admin, late, ); const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: result.alarmId }, }); assert.equal(alarm.status, "DECLINED"); assert.equal(alarm.responseSource, "PHONE"); const jobs = await prisma.notdienstAlarmEmail.findMany({ where: { alarmId: alarm.id }, }); assert.ok( jobs.every( (job) => !job.message.includes("PRIVATE_") && !job.message.includes("Interne Fachkraft"), ), ); }); test("Stornierung invalidiert alte Links, informiert den Haushalt und erlaubt neue Alarmierung", async () => { const f = await fixture(), result = await f.create(); await assert.rejects( assertAssignmentNotAlarmed(prisma, f.assignment.id, f.kita.id), /aktiver Alarm/, ); const token = (await f.recipients(result.alarmId!))[0].token; const cancelled = await f.command("cancel", result.alarmId!, { reason: "PRIVATE_STORNIERUNG", }); assert.equal(cancelled.emailIds.length, 2); await assert.rejects(respondToAlarm(token, true, now), /storniert/); await assertAssignmentNotAlarmed(prisma, f.assignment.id, f.kita.id); const renewed = await f.create(); assert.notEqual(renewed.alarmId, result.alarmId); assert.equal((await getPublicAlarm(token))?.status, "CANCELLED"); let requests = 0; await deliverAlarmEmails( result.emailIds, async () => { requests++; return { success: true }; }, now, ); assert.equal(requests, 0); }); test("Versandfehler stoppen keine anderen Empfänger; erneuter Versand lässt Frist unverändert", async () => { const f = await fixture(), result = await f.create(); let calls = 0; const messages: string[] = []; const delivery = await deliverAlarmEmails( result.emailIds, async (input) => { const number = ++calls; messages.push(await render(input.react)); assert.ok(input.idempotencyKey?.startsWith("notdienst-alarm/")); return number === 1 ? { success: false, error: "Testausfall" } : { success: true }; }, now, ); assert.equal(delivery.failed, 1); assert.equal(delivery.sent, 1); assert.ok( messages.every( (html) => html.includes("2090-01-09") && html.includes("Ganzer Kita-Tag"), ), ); const original = await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: result.alarmId }, }); const retry = await f.command("retry", original.id); assert.equal(retry.emailIds.length, 1); assert.equal( ( await deliverAlarmEmails( retry.emailIds, async () => ({ success: true }), now, ) ).sent, 1, ); assert.equal( ( await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: original.id }, }) ).deadline.getTime(), original.deadline.getTime(), ); }); test("Paralleler Versand verwendet Aufträge einmal; geänderte Haushalte erhalten keine Anfrage", async () => { const f = await fixture(), result = await f.create(); let calls = 0; const send = async () => { calls++; return { success: true as const }; }; await Promise.all([ deliverAlarmEmails(result.emailIds, send, now), deliverAlarmEmails(result.emailIds, send, now), ]); assert.equal(calls, 2); const next = await fixture(), queued = await next.create(); await prisma.user.update({ where: { id: next.p1.id }, data: { familyId: next.otherFamily.id }, }); const token = (await next.recipients(queued.alarmId!)).find( (r) => r.userId === next.p1.id, )!.token; assert.equal(await getPublicAlarm(token), null); const sent = await deliverAlarmEmails(queued.emailIds, send, now); assert.equal(sent.sent, 1); }); test("Abgelaufene Tokens können nicht antworten und abgelaufene Anfragen werden nicht versendet", async () => { const f = await fixture(), result = await f.create({ wholeDay: false, startMinute: 480, endMinute: 540, }); const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: result.alarmId }, }); await assert.rejects( respondToAlarm( (await f.recipients(alarm.id))[0].token, true, alarm.expiresAt, ), /abgelaufen/, ); let calls = 0; await deliverAlarmEmails( result.emailIds, async () => { calls++; return { success: true }; }, alarm.expiresAt, ); assert.equal(calls, 0); await processAlarmDeadlines(alarm.expiresAt, f.kita.id); assert.equal( (await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: alarm.id } })) .activeAssignmentId, null, ); }); test("Bisherige Einzelalarme werden vorsichtig übernommen und Originalstatus bleibt erhalten", async () => { const f = await fixture(); for (const [user, status] of [ [f.p1, "CONFIRMED"], [f.p2, "PENDING"], ] as const) await prisma.notdienstAlert.create({ data: { kitaId: f.kita.id, assignmentId: f.assignment.id, parentUserId: user.id, confirmationToken: randomUUID(), status, triggeredAt: now, }, }); await assert.rejects( assertAssignmentNotAlarmed(prisma, f.assignment.id, f.kita.id), /aktiver Alarm/, ); assert.equal(await importLegacyAlarms(f.kita.id), 1); assert.equal(await importLegacyAlarms(f.kita.id), 0); const alarm = await prisma.notdienstAlarm.findFirstOrThrow({ where: { kitaId: f.kita.id }, }); assert.equal(alarm.status, "REVIEW_REQUIRED"); assert.equal( await prisma.notdienstAlarmEmail.count({ where: { alarmId: alarm.id } }), 0, ); const legacy = await prisma.notdienstAlert.findMany({ where: { kitaId: f.kita.id }, }); assert.deepEqual(legacy.map((l) => l.status).sort(), [ "CONFIRMED", "PENDING", ]); assert.equal( (await getPublicAlarm(legacy[0].confirmationToken))?.status, "REVIEW_REQUIRED", ); }); test("Gültige alte Links werden gemeinsam übernommen; unzuständige Konten können sie nicht benutzen", async () => { const f = await fixture(); const tokens = [randomUUID(), randomUUID()]; for (const [index, user] of [f.p1, f.p2].entries()) await prisma.notdienstAlert.create({ data: { kitaId: f.kita.id, assignmentId: f.assignment.id, parentUserId: user.id, confirmationToken: tokens[index], triggeredAt: now, }, }); assert.equal((await getPublicAlarm(tokens[0]))?.status, "PENDING"); await respondToAlarm(tokens[0], true, now); assert.equal((await getPublicAlarm(tokens[1]))?.status, "CONFIRMED"); await prisma.user.update({ where: { id: f.p2.id }, data: { role: "ERZIEHER" }, }); assert.equal(await getPublicAlarm(tokens[1]), null); await assert.rejects(respondToAlarm(tokens[1], false, now), /Ungültiger/); }); test("Jobzugriff benötigt ein eigenes langes Secret und validiert den Bearer-Header", () => { const secret = "test-only-notdienst-alarm-secret-0123456789"; assert.equal(authorizeAlarmJob(null, ""), 503); assert.equal(authorizeAlarmJob(null, secret), 401); assert.equal(authorizeAlarmJob(`Bearer ${secret}x`, secret), 401); assert.equal(authorizeAlarmJob(`Bearer ${secret}`, secret), 200); });