Merge current features with security hardening and automatic safe initialization

This commit is contained in:
t.indorf
2026-10-09 23:29:19 +02:00
85 changed files with 13522 additions and 1326 deletions
+820
View File
@@ -0,0 +1,820 @@
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { after, test, mock } 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 {
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 !== "5433" ||
db.pathname !== "/kita_security_test"
)
throw new Error(
"Alarmtests benötigen die isolierte Datenbank 127.0.0.1:5433/kita_security_test.",
);
process.env.AUTH_URL = "http://127.0.0.1:3008";
let testUser: { id: string; role: UserRole; kitaId: string; familyId: string } | null = null;
const testSession = () => { if (!testUser) throw new Error('No test session'); return { user: testUser }; };
mock.module('../src/lib/auth-utils.ts', { namedExports: {
requireKitaSession: async () => testSession(),
requireRole: async (roles: UserRole[]) => { const session = testSession(); if (!roles.includes(session.user.role)) throw new Error('Forbidden'); return session; },
} });
const loadAfterMocks = createRequire(resolve(process.cwd(), 'tests/notdienst-alarm.test.ts'));
const { updateAssignmentAction } = loadAfterMocks('../src/app/dashboard/notdienst/plan/actions') as typeof import('../src/app/dashboard/notdienst/plan/actions');
const { createNotdienstSwapRequest, acceptNotdienstSwapRequest } = loadAfterMocks('../src/app/dashboard/kalender/actions') as typeof import('../src/app/dashboard/kalender/actions');
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(),
emailVerifiedAt: 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<string, unknown> = {},
who = admin,
time = now,
) =>
executeAlarmCommand(
who,
"create",
{ assignmentId: assignment.id, wholeDay: true, ...data },
time,
);
const command = (
name: string,
alarmId: string,
data: Record<string, unknown> = {},
who = coord,
time = now,
) => executeAlarmCommand(who, name, { alarmId, ...data }, time);
const recipients = async (alarmId: string) => {
const rows = await prisma.notdienstAlarmRecipient.findMany({ where: { alarmId }, orderBy: { userId: "asc" } });
const jobs = await prisma.notdienstAlarmEmail.findMany({ where: { alarmId, kind: "REQUEST" } });
return rows.map(row => ({ ...row, token: jobs.find(job => job.userId === row.userId)?.path.split("/").pop() ?? row.token }));
};
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(),
emailVerifiedAt: 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();
function login(actor: AlarmActor, familyId: string) { testUser = { ...actor, 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 {
testUser = null;
}
});
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);
});
+416
View File
@@ -0,0 +1,416 @@
import { strict as assert } from "node:assert";
import { mock, test } from "node:test";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { randomUUID } from "node:crypto";
import {
AnnouncementAudience,
EventParticipationStatus as S,
PrismaClient,
RsvpMode,
TerminStatus,
TerminType,
UserRole,
} from "@prisma/client";
// No database work without an explicitly supplied local test database.
test(
"Elternabläufe gegen PostgreSQL",
{ skip: !process.env.TEST_DATABASE_URL },
async (t) => {
const url = new URL(process.env.TEST_DATABASE_URL!);
assert.ok(
["127.0.0.1", "localhost"].includes(url.hostname),
"Nur lokale Testdatenbanken sind erlaubt",
);
assert.ok(
/test|parent_flows/.test(url.pathname),
"Der Datenbankname muss ausdrücklich eine Testdatenbank kennzeichnen",
);
process.env.DATABASE_URL = url.toString();
const db = new PrismaClient();
const id = randomUUID();
const kitaId = `parent-test-${id}`;
const otherKitaId = `other-test-${id}`;
let user = {
id: `parent-a-${id}`,
kitaId,
familyId: `family-${id}`,
role: UserRole.ELTERN as UserRole,
};
const moduleMock = mock as typeof mock & {
module: (
specifier: string,
options: { namedExports: Record<string, unknown> },
) => unknown;
};
moduleMock.module("../src/lib/auth-utils.ts", {
namedExports: {
requireKitaSession: async () => ({ user }),
getKitaSession: async () => ({ user }),
requireRole: async (roles: UserRole[]) => {
assert.ok(roles.includes(user.role));
return { user };
},
},
});
moduleMock.module("../src/auth.ts", {
namedExports: { auth: async () => ({ user }) },
});
moduleMock.module("next/cache", {
namedExports: { revalidatePath: () => {} },
});
const loadAfterMocks = createRequire(
resolve("tests/parent-flows.integration.test.ts"),
);
const { submitEventRsvp } = loadAfterMocks(
"../src/actions/rsvp",
) as typeof import("../src/actions/rsvp");
const { updateTerminAdmin } = loadAfterMocks(
"../src/app/dashboard/kalender/actions",
) as typeof import("../src/app/dashboard/kalender/actions");
const { createAnnouncement, markAnnouncementRead } = loadAfterMocks(
"../src/actions/announcements",
) as typeof import("../src/actions/announcements");
const { GET: getAttachment } = loadAfterMocks(
"../src/app/api/announcements/attachments/[id]/route",
) as typeof import("../src/app/api/announcements/attachments/[id]/route");
const familyId = user.familyId;
const startDate = new Date(Date.now() + 86400000 * 3);
const endDate = new Date(startDate.getTime() + 3600000);
const eventBase = {
kitaId,
title: "Testtermin",
type: TerminType.SONSTIGES,
status: TerminStatus.CONFIRMED,
startDate,
endDate,
requiresRsvp: true,
};
const updateBase = {
title: "Testtermin geändert",
type: TerminType.SONSTIGES,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
};
let teacherAnnouncementId = "";
try {
await db.kita.create({
data: { id: kitaId, name: "Elternabläufe Test", slug: kitaId },
});
await db.kita.create({
data: { id: otherKitaId, name: "Andere Kita", slug: otherKitaId },
});
await db.family.create({
data: { id: familyId, kitaId, name: "Testfamilie" },
});
await db.family.create({
data: {
id: `other-family-${id}`,
kitaId: otherKitaId,
name: "Fremde Familie",
},
});
await db.user.create({
data: {
id: user.id,
kitaId,
familyId,
email: `${id}@example.test`,
passwordHash: "test",
firstName: "Test",
lastName: "Eltern",
},
});
const children = await Promise.all(
["a", "b"].map((suffix) =>
db.child.create({
data: {
id: `${suffix}-${id}`,
kitaId,
familyId,
firstName: suffix,
lastName: "Test",
},
}),
),
);
const foreign = await db.child.create({
data: {
kitaId: otherKitaId,
familyId: `other-family-${id}`,
firstName: "Fremd",
lastName: "Test",
},
});
const inactive = await db.child.create({
data: {
kitaId,
familyId,
firstName: "Inaktiv",
lastName: "Test",
active: false,
},
});
const familyEvent = await db.termin.create({
data: { ...eventBase, rsvpMode: RsvpMode.FAMILY },
});
const childEvent = await db.termin.create({
data: {
...eventBase,
rsvpMode: RsvpMode.CHILD,
mitbringselListEnabled: true,
participantInfo: "Nur Teilnehmende",
},
});
const familyPayload = {
eventId: familyEvent.id,
mode: RsvpMode.FAMILY,
status: S.ATTENDING,
adultCount: 2,
};
const childPayload = {
eventId: childEvent.id,
mode: RsvpMode.CHILD,
responses: [
{ childId: children[0].id, status: S.ATTENDING },
{ childId: children[1].id, status: S.NOT_ATTENDING },
],
};
await t.test(
"Zwei Elternkonten ändern eine gemeinsame Familienantwort",
async () => {
assert.equal((await submitEventRsvp(familyPayload)).success, true);
user = { ...user, id: `parent-b-${id}` };
assert.equal(
(await submitEventRsvp({ ...familyPayload, adultCount: 3 }))
.success,
true,
);
assert.equal(
await db.familyEventParticipation.count({
where: { eventId: familyEvent.id },
}),
1,
);
assert.equal(
(
await db.familyEventParticipation.findFirstOrThrow({
where: { eventId: familyEvent.id },
})
).adultCount,
3,
);
assert.ok(
(await submitEventRsvp({ ...familyPayload, adultCount: 0 })).error,
);
assert.equal(
(
await submitEventRsvp({
...familyPayload,
status: S.NOT_ATTENDING,
adultCount: 0,
})
).success,
true,
);
assert.equal(
(
await db.familyEventParticipation.findFirstOrThrow({
where: { eventId: familyEvent.id },
})
).adultCount,
0,
);
},
);
await t.test(
"Alle Kinder werden gemeinsam gespeichert, fremde/inaktive Kinder abgewiesen",
async () => {
assert.equal((await submitEventRsvp(childPayload)).success, true);
for (const childId of [foreign.id, inactive.id]) {
assert.ok(
(
await submitEventRsvp({
...childPayload,
responses: [
{ childId: children[0].id, status: S.NOT_ATTENDING },
{ childId, status: S.ATTENDING },
],
})
).error,
);
}
assert.ok(
(
await submitEventRsvp({
...childPayload,
responses: [childPayload.responses[0]],
})
).error,
);
const rows = await db.eventParticipation.findMany({
where: { eventId: childEvent.id },
orderBy: { childId: "asc" },
});
assert.equal(rows.length, 2);
assert.equal(rows[0].status, S.ATTENDING);
assert.equal(rows[1].status, S.NOT_ATTENDING);
},
);
await t.test(
"Falsche Antwortart und fremde Kita können nicht beschrieben werden",
async () => {
assert.ok(
(
await submitEventRsvp({
...familyPayload,
eventId: childEvent.id,
})
).error,
);
user = { ...user, kitaId: otherKitaId };
assert.ok((await submitEventRsvp(familyPayload)).error);
user = { ...user, kitaId };
},
);
await t.test(
"Frist, Absage und deaktiviertes Terminmodul verhindern Antworten",
async () => {
await db.termin.update({
where: { id: familyEvent.id },
data: { rsvpDeadline: new Date(Date.now() - 1000) },
});
assert.ok((await submitEventRsvp(familyPayload)).error);
await db.termin.update({
where: { id: familyEvent.id },
data: { rsvpDeadline: null, status: TerminStatus.CANCELLED },
});
assert.ok((await submitEventRsvp(familyPayload)).error);
await db.termin.update({
where: { id: familyEvent.id },
data: { status: TerminStatus.CONFIRMED },
});
await db.kita.update({
where: { id: kitaId },
data: { terminModuleEnabled: false },
});
assert.ok((await submitEventRsvp(familyPayload)).error);
await db.kita.update({
where: { id: kitaId },
data: { terminModuleEnabled: true },
});
},
);
await t.test(
"Normale Terminänderung erhält RSVP-Felder; Antwortart ist nach Antwort gesperrt",
async () => {
user = { ...user, role: UserRole.ADMIN };
assert.equal(
(await updateTerminAdmin(childEvent.id, updateBase)).success,
true,
);
const event = await db.termin.findUniqueOrThrow({
where: { id: childEvent.id },
});
assert.equal(event.requiresRsvp, true);
assert.equal(event.mitbringselListEnabled, true);
assert.equal(event.participantInfo, "Nur Teilnehmende");
assert.ok(
(
await updateTerminAdmin(childEvent.id, {
...updateBase,
rsvpMode: RsvpMode.FAMILY,
})
).error,
);
},
);
await t.test(
"Gleichzeitige erste Antwort und Modusänderung bleiben widerspruchsfrei",
async () => {
const event = await db.termin.create({
data: { ...eventBase, rsvpMode: RsvpMode.FAMILY },
});
await Promise.all([
submitEventRsvp({ ...familyPayload, eventId: event.id }),
updateTerminAdmin(event.id, {
...updateBase,
rsvpMode: RsvpMode.CHILD,
}),
]);
const stored = await db.termin.findUniqueOrThrow({
where: { id: event.id },
include: { familyParticipations: true },
});
assert.ok(
stored.rsvpMode === RsvpMode.FAMILY
? stored.familyParticipations.length === 1
: stored.familyParticipations.length === 0,
);
},
);
await t.test(
"Empfängergruppe wird auch ohne Mail gespeichert",
async () => {
user = { ...user, id: `parent-a-${id}`, role: UserRole.ADMIN };
const form = new FormData();
form.set("title", "Nur ErzieherInnen");
form.set("content", "Interne Teammitteilung");
form.set("audience", "ERZIEHER");
assert.equal((await createAnnouncement(form)).success, true);
const announcement = await db.announcement.findFirstOrThrow({
where: { kitaId, title: "Nur ErzieherInnen" },
});
assert.equal(announcement.audience, AnnouncementAudience.ERZIEHER);
teacherAnnouncementId = announcement.id;
await db.attachment.create({
data: {
id: `teacher-attachment-${id}`,
announcementId: announcement.id,
fileName: "team.pdf",
fileUrl: `/api/announcements/attachments/teacher-attachment-${id}`,
fileType: "application/pdf",
},
});
},
);
await t.test(
"Eltern können Teammitteilungen weder als gelesen markieren noch Anhänge abrufen",
async () => {
user = { ...user, role: UserRole.ELTERN };
assert.ok((await markAnnouncementRead(teacherAnnouncementId)).error);
const response = await getAttachment(
new Request("http://localhost/test"),
{ params: Promise.resolve({ id: `teacher-attachment-${id}` }) },
);
assert.equal(response.status, 404);
assert.equal(
await db.announcementRead.count({
where: { announcementId: teacherAnnouncementId },
}),
0,
);
user = { ...user, role: UserRole.ERZIEHER };
assert.equal(
(await markAnnouncementRead(teacherAnnouncementId)).success,
true,
);
user = { ...user, kitaId: otherKitaId };
assert.ok((await markAnnouncementRead(teacherAnnouncementId)).error);
},
);
} finally {
await db.kita.deleteMany({
where: { id: { in: [kitaId, otherKitaId] } },
});
await db.$disconnect();
const { prisma } = loadAfterMocks(
"../src/lib/prisma",
) as typeof import("../src/lib/prisma");
await prisma.$disconnect();
mock.restoreAll();
}
},
);
+143
View File
@@ -0,0 +1,143 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import {
AnnouncementAudience as A,
EventParticipationStatus as S,
RsvpMode,
TerminType,
UserRole,
} from "@prisma/client";
import {
announcementRecipientWhere,
visibleAnnouncementAudiences,
} from "../src/lib/announcement-access";
import {
hasMissingRsvp,
rsvpSubmissionSchema,
suggestedRsvpMode,
type RsvpEventDto,
} from "../src/lib/rsvp";
const family = {
eventId: "event",
mode: RsvpMode.FAMILY,
status: S.ATTENDING,
adultCount: 2,
};
test("Familienzusage braucht eine positive ganze Erwachsenenanzahl", () => {
assert.equal(rsvpSubmissionSchema.safeParse(family).success, true);
for (const adultCount of [0, -1, 1.5, 2147483648, NaN]) {
assert.equal(
rsvpSubmissionSchema.safeParse({ ...family, adultCount }).success,
false,
);
}
});
test("Familienabsage setzt Erwachsenenanzahl auf null", () => {
assert.equal(
rsvpSubmissionSchema.safeParse({
...family,
status: S.NOT_ATTENDING,
adultCount: 0,
}).success,
true,
);
assert.equal(
rsvpSubmissionSchema.safeParse({ ...family, status: S.NOT_ATTENDING })
.success,
false,
);
});
test("Kinderantworten dürfen verschiedene Zusagen enthalten, keine Duplikate oder leere Liste", () => {
const payload = {
eventId: "event",
mode: RsvpMode.CHILD,
responses: [
{ childId: "a", status: S.ATTENDING },
{ childId: "b", status: S.NOT_ATTENDING },
],
};
assert.equal(rsvpSubmissionSchema.safeParse(payload).success, true);
assert.equal(
rsvpSubmissionSchema.safeParse({ ...payload, responses: [] }).success,
false,
);
assert.equal(
rsvpSubmissionSchema.safeParse({
...payload,
responses: [payload.responses[0], payload.responses[0]],
}).success,
false,
);
});
test("Eltern sehen Elternmeldungen und gemeinsame Meldungen", () => {
assert.deepEqual(
visibleAnnouncementAudiences({ role: UserRole.ELTERN, familyId: "f" }),
[A.ALLE, A.ELTERN],
);
assert.deepEqual(
visibleAnnouncementAudiences({ role: UserRole.KOORDINATOR, familyId: "f" }),
[A.ALLE, A.ELTERN],
);
});
test("Erzieherkonto erhält auch mit Haushaltszuordnung keine Elternmeldungen", () => {
assert.deepEqual(
visibleAnnouncementAudiences({ role: UserRole.ERZIEHER, familyId: "f" }),
[A.ALLE, A.ERZIEHER],
);
});
test("Admin darf alle Meldungen verwalten", () => {
assert.deepEqual(
new Set(visibleAnnouncementAudiences({ role: UserRole.ADMIN })),
new Set(Object.values(A)),
);
});
test("E-Mail-Gruppen schließen Erzieher aus Elternempfängern aus", () => {
assert.deepEqual(announcementRecipientWhere(A.ERZIEHER), {
role: UserRole.ERZIEHER,
});
assert.deepEqual(announcementRecipientWhere(A.ELTERN), {
role: { not: UserRole.ERZIEHER },
OR: [{ familyId: { not: null } }, { role: UserRole.ELTERN }],
});
});
test("Die Rückmeldekarte verschwindet erst, wenn jedes Kind geantwortet hat", () => {
const event: RsvpEventDto = {
id: "e",
title: "Ausflug",
startDate: "2030-01-01",
rsvpDeadline: null,
mode: RsvpMode.CHILD,
canReply: true,
familyResponse: null,
children: [
{ id: "a", name: "A", status: S.ATTENDING },
{ id: "b", name: "B", status: null },
],
};
assert.equal(hasMissingRsvp(event), true);
assert.equal(
hasMissingRsvp({
...event,
children: event.children.map((child) => ({
...child,
status: S.NOT_ATTENDING,
})),
}),
false,
);
assert.equal(hasMissingRsvp({ ...event, mode: RsvpMode.FAMILY }), true);
assert.equal(
hasMissingRsvp({
...event,
mode: RsvpMode.FAMILY,
familyResponse: { status: S.NOT_ATTENDING, adultCount: 0 },
}),
false,
);
});
test("Antwortart wird passend zur Terminart vorgeschlagen", () => {
assert.equal(suggestedRsvpMode(TerminType.ELTERNABEND), RsvpMode.FAMILY);
assert.equal(suggestedRsvpMode(TerminType.SONSTIGES), RsvpMode.CHILD);
});
+29 -6
View File
@@ -23,6 +23,7 @@ function loader(overrides = {}) {
}).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() {} };
@@ -38,6 +39,7 @@ function loader(overrides = {}) {
}
return load;
}
process.env.AUTH_URL = 'http://127.0.0.1:3008';
let prisma, kitaA, kitaB, familyA, familyB, parentA, parentB, adminA, founder, superadmin, childA, childB, passHash;
const prefix = `security-${randomUUID()}`;
const emails = [];
@@ -255,14 +257,14 @@ test('participant secrets are removed from props delivered to unauthorized brows
assert.equal(JSON.stringify(await page(parentB)).includes(secret), false);
});
test('alarm links expire and concurrent confirmations consume the token only once', async () => {
const load = loader(); const { newToken, hashToken } = load('src/lib/tokens.ts');
const load = loader({ '@/lib/mail': emailMock }); const { newToken, hashToken } = load('src/lib/tokens.ts');
const plan = await prisma.notdienstPlan.create({ data: { kitaId: kitaA.id, year: 2029, month: 1, status: 'PUBLISHED' } });
const assignment = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: childA.id, date: new Date(Date.now() + 86400000) } });
const token = newToken();
const alert = await prisma.notdienstAlert.create({ data: { kitaId: kitaA.id, assignmentId: assignment.id, parentUserId: parentA.id, confirmationToken: hashToken(token), triggeredAt: new Date(Date.now() - 48 * 3600000) } });
const alert = await prisma.notdienstAlarm.create({ data: { kitaId: kitaA.id, assignmentId: assignment.id, activeAssignmentId: assignment.id, familyId: familyA.id, familyName: 'A', childName: 'A Test', date: assignment.date, expiresAt: new Date(Date.now() - 1000), deadline: new Date(Date.now() + 3600000), recipients: { create: { userId: parentA.id, token: hashToken(token), sessionVersion: parentA.sessionVersion } } } });
const confirm = load('src/app/alert/[token]/actions.ts').confirmAlertAction;
assert.ok((await confirm(token)).error);
await prisma.notdienstAlert.update({ where: { id: alert.id }, data: { triggeredAt: new Date() } });
await prisma.notdienstAlarm.update({ where: { id: alert.id }, data: { expiresAt: new Date(Date.now() + 86400000) } });
const results = await Promise.all([confirm(token), confirm(token)]);
assert.equal(results.filter(result => result.success).length, 1);
assert.ok((await confirm(token)).error);
@@ -353,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,31);
assert.equal(row.count,41);
});
test('role guard authenticates once and keeps role validation on that session', async () => {
let calls = 0;
@@ -370,9 +372,30 @@ test('parallel alarm triggers send one set of messages and skip unverified recip
const before = emails.length;
const results = await Promise.all([trigger(assignment.id),trigger(assignment.id)]);
assert.equal(results.filter(result => result.success).length,1);
const alerts = await prisma.notdienstAlert.findMany({ where: { assignmentId: assignment.id } });
const alerts = await prisma.notdienstAlarmRecipient.findMany({ where: { alarm: { assignmentId: assignment.id } } });
assert.ok(alerts.length > 0);
assert.equal(alerts.some(alert => alert.parentUserId === unverified.id),false);
assert.equal(alerts.some(alert => alert.userId === unverified.id),false);
assert.equal(emails.slice(before).some(email => email.to === unverified.email),false);
assert.equal(emails.length - before,alerts.length);
});
test('new alarm links reject stored hashes and stay invalid after account unlock', async () => {
const load = loader(); const { newToken, hashToken } = load('src/lib/tokens.ts');
const { getPublicAlarm } = load('src/lib/alarms/service.ts');
const user = await prisma.user.create({ data: { email: `alarm-version.${prefix}@example.invalid`, firstName: 'Parent', lastName: 'Test', role: 'ELTERN', kitaId: kitaA.id, familyId: familyA.id, passwordHash: passHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
const token = newToken();
await prisma.notdienstAlarm.create({ data: { kitaId: kitaA.id, familyId: familyA.id, familyName: 'A', childName: 'A', date: new Date(), expiresAt: new Date(Date.now() + 86400000), deadline: new Date(Date.now() + 3600000), recipients: { create: { userId: user.id, token: hashToken(token), sessionVersion: user.sessionVersion } } } });
assert.ok(await getPublicAlarm(token));
assert.equal(await getPublicAlarm(hashToken(token)), null);
await prisma.user.update({ where: { id: user.id }, data: { disabledAt: new Date(), sessionVersion: { increment: 1 } } });
assert.equal(await getPublicAlarm(token), null);
await prisma.user.update({ where: { id: user.id }, data: { disabledAt: null } });
assert.equal(await getPublicAlarm(token), null);
});
test('new staff and household event relationships reject foreign tenants in the database', async () => {
const educator = await prisma.educator.create({ data: { kitaId: kitaA.id, firstName: 'Staff', lastName: 'Test' } });
await assert.rejects(prisma.staffProfile.create({ data: { kitaId: kitaB.id, educatorId: educator.id, employmentStart: new Date(), trackingStart: new Date() } }));
const event = await prisma.termin.create({ data: { kitaId: kitaA.id, title: 'Tenant event', type: 'SONSTIGES', status: 'CONFIRMED', startDate: new Date(), endDate: new Date(Date.now() + 3600000), createdById: adminA.id } });
await assert.rejects(prisma.familyEventParticipation.create({ data: { kitaId: kitaA.id, eventId: event.id, familyId: familyB.id, status: 'ATTENDING' } }));
});
+237
View File
@@ -0,0 +1,237 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
calculateLeaveDays,
calculateWeek,
dateOnly,
dateSchema,
getTimeBalance,
scheduledMinutes,
todayKey,
vacationBalance,
weekStart,
} from "../src/lib/staff/calculations";
const profile = {
employmentStart: dateOnly("2026-01-01"),
employmentEnd: null,
trackingStart: dateOnly("2026-01-01"),
openingMinutes: 120,
workPatterns: [
{
effectiveFrom: dateOnly("2026-01-01"),
dailyMinutes: [360, 360, 0, 360, 0, 0, 0],
},
],
dayOverrides: [],
};
const holidays = new Set(["2026-01-06"]);
const days = (date: string, vacationUnits: number, creditMinutes = 360) => [
{ date: dateOnly(date), vacationUnits, creditMinutes },
];
test("Teilzeiturlaub verbraucht nur feste Arbeitstage, Feiertage werden übersprungen", () => {
const result = calculateLeaveDays(profile, holidays, {
kind: "VACATION",
startDate: "2026-01-05",
endDate: "2026-01-11",
halfDay: false,
});
assert.deepEqual(
result.map((d) => d.date),
["2026-01-05", "2026-01-08"],
);
assert.equal(
result.reduce((s, d) => s + d.vacationUnits, 0),
4,
);
});
test("Halber Urlaubstag entspricht halbem Tages-Soll", () => {
assert.deepEqual(
calculateLeaveDays(profile, new Set(), {
kind: "VACATION",
startDate: "2026-01-05",
endDate: "2026-01-05",
halfDay: true,
}),
[{ date: "2026-01-05", vacationUnits: 1, creditMinutes: 180 }],
);
assert.throws(() =>
calculateLeaveDays(profile, holidays, {
kind: "VACATION",
startDate: "2026-01-05",
endDate: "2026-01-08",
halfDay: true,
}),
);
});
test("Tagesausnahmen und datierte Modelle verändern nur den vorgesehenen Zeitraum", () => {
const schedule = {
...profile,
dayOverrides: [{ date: dateOnly("2026-01-07"), minutes: 240 }],
workPatterns: [
...profile.workPatterns,
{
effectiveFrom: dateOnly("2026-02-01"),
dailyMinutes: [480, 0, 480, 0, 0, 0, 0],
},
],
};
assert.equal(scheduledMinutes(schedule, "2026-01-07", holidays), 240);
assert.equal(scheduledMinutes(schedule, "2026-01-05", holidays), 360);
assert.equal(scheduledMinutes(schedule, "2026-02-02", holidays), 480);
assert.equal(scheduledMinutes(schedule, "2026-01-06", holidays), 0);
assert.equal(
scheduledMinutes(
{ ...schedule, employmentEnd: dateOnly("2026-01-31") },
"2026-02-02",
holidays,
),
0,
);
});
test("Urlaub über Jahreswechsel wird auf die Jahreskonten verteilt", () => {
const leave = [
{
kind: "VACATION",
status: "APPROVED",
days: [...days("2026-12-31", 2), ...days("2027-01-04", 1)],
},
];
assert.equal(
vacationBalance(
{ year: 2026, entitlementUnits: 40, carryUnits: 2 },
leave,
"2026-10-01",
).planned,
2,
);
assert.equal(
vacationBalance(
{ year: 2027, entitlementUnits: 40, carryUnits: 0 },
leave,
"2026-10-01",
).planned,
1,
);
});
test("Offene Stornierung bleibt gebucht, Ablehnung und Stornierung verbrauchen nichts", () => {
const leave = [
{
kind: "VACATION",
status: "CANCEL_REQUESTED",
days: days("2026-01-05", 2),
},
{ kind: "VACATION", status: "PENDING", days: days("2026-01-08", 1) },
...["CANCELLED", "REJECTED"].map((status) => ({
kind: "VACATION",
status,
days: days("2026-01-12", 2),
})),
];
assert.deepEqual(
vacationBalance(
{ year: 2026, entitlementUnits: 40, carryUnits: 4 },
leave,
"2026-01-07",
),
{ entitlement: 44, taken: 2, planned: 0, pending: 1, available: 42 },
);
});
test("Mehrere Arbeitsblöcke abzüglich Pausen, Urlaub und Krankheit ergeben die Wochenbilanz", () => {
const entries = [
{
date: dateOnly("2026-01-05"),
startMinute: 480,
endMinute: 720,
breakMinutes: 30,
},
{
date: dateOnly("2026-01-05"),
startMinute: 780,
endMinute: 960,
breakMinutes: 0,
},
];
const leave = [
{ kind: "SICKNESS", status: "APPROVED", days: days("2026-01-08", 0) },
];
const result = calculateWeek(profile, holidays, "2026-01-05", entries, leave);
assert.equal(result[0].worked, 390);
assert.equal(result[0].balance, 30);
assert.equal(result[1].expected, 0);
assert.equal(result[3].credit, 360);
assert.equal(result[3].balance, 0);
});
test("Nur genehmigte Snapshots zählen; Zukunftskorrekturen zählen erst ab Datum", () => {
const snapshot = [
{
date: "2026-01-05",
expected: 360,
worked: 420,
credit: 0,
timeOff: 0,
balance: 60,
},
];
const result = getTimeBalance(
profile,
[
{ status: "APPROVED", dailySnapshot: snapshot },
{ status: "SUBMITTED", dailySnapshot: snapshot },
],
[{ date: dateOnly("2026-02-01"), minutes: 100 }],
[],
"2026-01-10",
);
assert.equal(result.confirmed, 180);
});
test("Freizeitausgleich wird reserviert und nach Wochenbuchung genau einmal abgezogen", () => {
const leave = [
{ kind: "TIME_OFF", status: "APPROVED", days: days("2026-01-05", 0, 60) },
];
const initial = getTimeBalance(profile, [], [], leave, "2026-01-04");
assert.deepEqual(initial, { confirmed: 120, reserved: 60, available: 60 });
const snapshot = [
{
date: "2026-01-05",
expected: 360,
worked: 300,
credit: 0,
timeOff: 60,
balance: -60,
},
];
assert.deepEqual(
getTimeBalance(
profile,
[{ status: "APPROVED", dailySnapshot: snapshot }],
[],
leave,
"2026-01-06",
),
{ confirmed: 60, reserved: 0, available: 60 },
);
const computed = calculateWeek(
profile,
new Set(),
"2026-01-05",
[
{
date: dateOnly("2026-01-05"),
startMinute: 480,
endMinute: 780,
breakMinutes: 0,
},
],
leave,
);
assert.equal(computed[0].credit, 0);
assert.equal(computed[0].balance, -60);
});
test("Ungültige Kalendertage werden abgewiesen; Tagesdatum wird in Berlin bestimmt", () => {
assert.equal(dateSchema.safeParse("2026-02-30").success, false);
assert.equal(dateSchema.safeParse("2028-02-29").success, true);
assert.equal(todayKey(new Date("2026-01-04T23:30:00Z")), "2026-01-05");
assert.equal(weekStart("2026-01-11"), "2026-01-05");
});
+984
View File
@@ -0,0 +1,984 @@
import { after, test } from "node:test";
import assert from "node:assert/strict";
import { hashToken } from "../src/lib/tokens";
import { randomUUID } from "node:crypto";
import { UserRole } from "@prisma/client";
import { prisma } from "../src/lib/prisma";
import {
executeStaffCommand,
getStaffProfile,
type StaffActor,
} from "../src/lib/staff/service";
import { dateOnly, getTimeBalance } from "../src/lib/staff/calculations";
import { deliverStaffEmails } from "../src/lib/staff/email";
import { render } from "@react-email/render";
process.env.AUTH_URL = "http://127.0.0.1:3008";
// Never run integration tests against a user's application database.
const db = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
if (
db.hostname !== "127.0.0.1" ||
db.port !== "5433" ||
db.pathname !== "/kita_security_test"
)
throw new Error(
"Integrationstests benötigen die isolierte Datenbank 127.0.0.1:5433/kita_security_test.",
);
const tenants: string[] = [];
after(async () => {
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
await prisma.$disconnect();
});
async function fixture() {
const kita = await prisma.kita.create({
data: { name: "Testkita", slug: `staff-test-${randomUUID()}` },
});
tenants.push(kita.id);
const createUser = (role: UserRole) =>
prisma.user.create({
data: {
kitaId: kita.id,
email: `${randomUUID()}@staff-test.invalid`,
passwordHash: "test-only",
firstName: "Test",
lastName: role,
role,
privacyPolicyAcceptedAt: new Date(),
emailVerifiedAt: new Date(),
},
});
const adminUser = await createUser("ADMIN"),
staffUser = await createUser("ERZIEHER"),
otherUser = await createUser("ERZIEHER"),
parentUser = await createUser("ELTERN");
const actor = (u: typeof adminUser): StaffActor => ({
id: u.id,
kitaId: kita.id,
role: u.role,
});
const admin = actor(adminUser),
staff = actor(staffUser),
other = actor(otherUser),
parent = actor(parentUser);
const educator = await prisma.educator.create({
data: { kitaId: kita.id, firstName: "Test", lastName: "Fachkraft" },
});
await executeStaffCommand(admin, {
command: "setup",
data: {
educatorId: educator.id,
employmentStart: "2026-01-01",
employmentEnd: null,
trackingStart: "2026-01-01",
openingMinutes: 600,
reason: "Testeinrichtung",
},
});
const profile = await prisma.staffProfile.update({
where: { educatorId: educator.id },
data: { userId: staffUser.id },
});
const run = (
who: StaffActor,
command: string,
data: Record<string, unknown> = {},
) =>
executeStaffCommand(who, {
command,
data: { profileId: profile.id, ...data },
});
await run(admin, "pattern", {
effectiveFrom: "2026-01-01",
dailyMinutes: [360, 360, 0, 360, 0, 0, 0],
reason: "Feste Teilzeit",
});
await run(admin, "account", {
year: 2026,
entitlementUnits: 40,
carryUnits: 0,
reason: "Jahresanspruch",
});
return {
kita,
profile,
educator,
admin,
staff,
other,
parent,
run,
staffUser,
};
}
test("E-Mail-Aufträge gehen an alle Admins derselben Kita; Versandfehler bleiben wiederholbar", async () => {
const f = await fixture(),
foreign = await fixture();
const second = await prisma.user.create({
data: {
kitaId: f.kita.id,
email: `${randomUUID()}@staff-test.invalid`,
passwordHash: "test-only",
firstName: "Zweiter",
lastName: "Admin",
role: "ADMIN",
privacyPolicyAcceptedAt: new Date(),
emailVerifiedAt: new Date(),
},
});
const result = await f.run(f.staff, "leave", {
...vacation(),
note: "VERTRAULICHE_NOTIZ",
});
const jobs = await prisma.staffEmail.findMany({
where: { id: { in: result.emailIds! } },
});
assert.deepEqual(
jobs.map((j) => j.userId).sort(),
[f.admin.id, second.id].sort(),
);
assert.ok(jobs.every((j) => !j.message.includes("VERTRAULICHE_NOTIZ")));
assert.equal((await f.run(f.other, "emailRetry")).emailIds?.length, 0);
assert.equal(
(await foreign.run(foreign.admin, "emailRetry")).emailIds?.length,
0,
);
const failure = await deliverStaffEmails(result.emailIds!, async () => ({
success: false,
error: "Testausfall",
}));
assert.equal(failure.failed, 2);
assert.equal(
await prisma.staffLeave.count({ where: { profileId: f.profile.id } }),
1,
);
const retry = await f.run(f.admin, "emailRetry");
const messages: string[] = [];
const delivered = await deliverStaffEmails(retry.emailIds!, async (input) => {
messages.push(await render(input.react));
assert.ok(input.idempotencyKey?.startsWith("staff-email/"));
return { success: true, id: "test-only" };
});
assert.equal(delivered.sent, 2);
assert.ok(
messages.every(
(html) =>
html.includes("2026-01-05") && !html.includes("VERTRAULICHE_NOTIZ"),
),
);
assert.equal((await f.run(f.admin, "emailRetry")).emailIds?.length, 0);
});
test("Fachkräfte schalten nur eigene Entscheidungsmails ab; interne Hinweise bleiben erhalten", async () => {
const f = await fixture();
await f.run(f.staff, "leave", vacation());
const leave = await prisma.staffLeave.findFirstOrThrow({
where: { profileId: f.profile.id },
});
const decision = await f.run(f.admin, "leaveReview", {
id: leave.id,
approve: true,
reason: "PRIVATE_BEGRUENDUNG",
});
assert.equal(decision.emailIds?.length, 1);
const queued = await prisma.staffEmail.findUniqueOrThrow({
where: { id: decision.emailIds![0] },
});
assert.equal(queued.userId, f.staff.id);
assert.ok(!queued.message.includes("PRIVATE_BEGRUENDUNG"));
await f.run(f.staff, "emailPreference", { enabled: false });
assert.equal(
(await prisma.user.findUniqueOrThrow({ where: { id: f.other.id } }))
.staffDecisionEmails,
true,
);
let calls = 0;
await deliverStaffEmails(decision.emailIds!, async () => {
calls++;
return { success: true };
});
assert.equal(calls, 0);
await f.run(f.staff, "leave", vacation("2026-01-06"));
const next = await prisma.staffLeave.findFirstOrThrow({
where: { profileId: f.profile.id, startDate: dateOnly("2026-01-06") },
});
const disabled = await f.run(f.admin, "leaveReview", {
id: next.id,
approve: false,
reason: "Testentscheidung",
});
assert.equal(disabled.emailIds?.length, 0);
assert.ok(
(await prisma.staffNotification.count({ where: { userId: f.staff.id } })) >=
2,
);
const sickness = await f.run(f.admin, "leave", {
...vacation("2026-01-08"),
kind: "SICKNESS",
});
assert.equal(sickness.emailIds?.length, 0);
await assert.rejects(
f.run(f.admin, "emailPreference", { enabled: false }),
/Fachkräfte/,
);
});
test("Arbeitswochen erzeugen getrennte Einreichungs- und Entscheidungsmails ohne private Rückmeldung", async () => {
const f = await fixture();
const submitted = await f.run(f.staff, "weekSubmit", {
weekStart: "2026-01-05",
});
const adminJob = await prisma.staffEmail.findUniqueOrThrow({
where: { id: submitted.emailIds![0] },
});
assert.equal(adminJob.userId, f.admin.id);
assert.ok(adminJob.path.includes("week=2026-01-05"));
const reviewed = await f.run(f.admin, "weekReview", {
weekStart: "2026-01-05",
approve: false,
reason: "PRIVATE_RUECKMELDUNG",
});
const job = await prisma.staffEmail.findUniqueOrThrow({
where: { id: reviewed.emailIds![0] },
});
assert.equal(job.userId, f.staff.id);
assert.ok(job.message.includes("zur Korrektur"));
assert.ok(!job.message.includes("PRIVATE_RUECKMELDUNG"));
});
test("Erneuerte Aktivierungslinks ersetzen ausstehende alte Mails und funktionieren trotz Opt-out", async () => {
const f = await fixture();
await prisma.staffProfile.update({
where: { id: f.profile.id },
data: { userId: null },
});
const email = `${randomUUID()}@staff-test.invalid`;
const old = await f.run(f.admin, "invite", { email });
const linked = await prisma.user.findUniqueOrThrow({ where: { email } });
await prisma.user.update({
where: { id: linked.id },
data: { staffDecisionEmails: false },
});
const renewed = await f.run(f.admin, "invite", { email });
const html: string[] = [];
const result = await deliverStaffEmails(
[...old.emailIds!, ...renewed.emailIds!],
async (input) => {
assert.equal(input.to, email);
html.push(await render(input.react, { plainText: true }));
return { success: true };
},
);
assert.equal(result.sent, 1);
assert.ok(html[0].includes(renewed.invite!.token));
assert.ok(!html[0].includes(old.invite!.token));
assert.ok(
html[0].includes("Fachkraft-Account") && html[0].includes("sieben Tage"),
);
});
test("Parallele Versandversuche senden einmal und beachten inzwischen entzogene Rollen", async () => {
const f = await fixture();
const result = await f.run(f.staff, "leave", vacation());
let calls = 0;
const send = async () => {
calls++;
return { success: true as const };
};
await Promise.all([
deliverStaffEmails(result.emailIds!, send),
deliverStaffEmails(result.emailIds!, send),
]);
assert.equal(calls, 1);
await deliverStaffEmails(result.emailIds!, send);
assert.equal(calls, 1);
const changed = await f.run(f.staff, "leave", vacation("2026-01-06"));
await prisma.user.update({
where: { id: f.admin.id },
data: { role: "ELTERN" },
});
await deliverStaffEmails(changed.emailIds!, send);
assert.equal(calls, 1);
});
const vacation = (date = "2026-01-05") => ({
kind: "VACATION",
startDate: date,
endDate: date,
halfDay: false,
note: "Testurlaub",
});
const time = (date = "2026-01-05", startMinute = 480, endMinute = 840) => ({
date,
startMinute,
endMinute,
breakMinutes: 0,
});
test("Mandanten, Rollen und Eigentümer werden für Lesen und Schreiben geprüft", async () => {
const f = await fixture(),
foreign = await fixture();
await assert.rejects(
f.run(f.other, "time", time()),
/eigenes Personalprofil/,
);
await assert.rejects(f.run(f.parent, "time", time()), /Kein Zugriff/);
await assert.rejects(
f.run(f.staff, "account", {
year: 2026,
entitlementUnits: 100,
carryUnits: 0,
reason: "Versuch",
}),
/Nur Kita-Admins/,
);
await assert.rejects(f.run(foreign.admin, "time", time()), /nicht gefunden/);
await assert.rejects(
f.run({ ...f.admin, kitaId: foreign.kita.id }, "time", time()),
/keinen Zugriff/,
);
assert.equal(await getStaffProfile(f.other, f.profile.id), null);
assert.equal(await getStaffProfile(foreign.admin, f.profile.id), null);
await assert.rejects(getStaffProfile(f.parent, f.profile.id), /Kein Zugriff/);
});
test("Urlaubsantrag, Freigabe, Stornierung und interne Hinweise bleiben konsistent", async () => {
const f = await fixture();
await f.run(f.staff, "leave", vacation());
const leave = await prisma.staffLeave.findFirstOrThrow({
where: { profileId: f.profile.id },
include: { days: true },
});
assert.equal(leave.days[0].vacationUnits, 2);
assert.equal(
await prisma.staffNotification.count({ where: { userId: f.admin.id } }),
1,
);
await f.run(f.admin, "leaveReview", {
id: leave.id,
approve: true,
reason: "Freigegeben",
});
await f.run(f.staff, "leaveCancel", { id: leave.id });
assert.equal(
(await prisma.staffLeave.findUniqueOrThrow({ where: { id: leave.id } }))
.status,
"CANCEL_REQUESTED",
);
await f.run(f.admin, "leaveReview", {
id: leave.id,
approve: true,
reason: "Stornierung bestätigt",
});
assert.equal(
(await prisma.staffLeave.findUniqueOrThrow({ where: { id: leave.id } }))
.status,
"CANCELLED",
);
assert.equal(
await prisma.staffNotification.count({ where: { userId: f.staff.id } }),
2,
);
const notice = await prisma.staffNotification.findFirstOrThrow({
where: { userId: f.staff.id },
});
await f.run(f.admin, "noticeRead", { id: notice.id });
assert.equal(
(
await prisma.staffNotification.findUniqueOrThrow({
where: { id: notice.id },
})
).readAt,
null,
);
await f.run(f.staff, "noticeRead", { id: notice.id });
assert.ok(
(
await prisma.staffNotification.findUniqueOrThrow({
where: { id: notice.id },
})
).readAt,
);
});
test("Offene Urlaube reservieren den Anspruch und lassen sich nur im offenen Status ändern", async () => {
const f = await fixture();
await f.run(f.admin, "account", {
year: 2026,
entitlementUnits: 2,
carryUnits: 0,
reason: "Ein Tag",
});
await f.run(f.staff, "leave", vacation());
await assert.rejects(
f.run(f.staff, "leave", vacation("2026-01-08")),
/reichen.*nicht aus/,
);
const leave = await prisma.staffLeave.findFirstOrThrow({
where: { profileId: f.profile.id },
});
await f.run(f.staff, "leave", { ...vacation("2026-01-08"), id: leave.id });
await f.run(f.admin, "leaveReview", {
id: leave.id,
approve: true,
reason: "Genehmigung",
});
await assert.rejects(
f.run(f.staff, "leave", { ...vacation(), id: leave.id }),
/Nur offene/,
);
});
test("Überlappende Arbeitsblöcke und parallele Urlaubsanträge werden verhindert", async () => {
const f = await fixture();
await f.run(f.staff, "time", time());
await assert.rejects(
f.run(f.staff, "time", time("2026-01-05", 800, 900)),
/überschneidet/,
);
await f.run(f.admin, "account", {
year: 2026,
entitlementUnits: 2,
carryUnits: 0,
reason: "Ein Tag",
});
const results = await Promise.allSettled([
f.run(f.staff, "leave", vacation("2026-01-08")),
f.run(f.staff, "leave", vacation("2026-01-12")),
]);
assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
assert.equal(
await prisma.staffLeave.count({ where: { profileId: f.profile.id } }),
1,
);
});
test("Wochenfreigabe schützt Zeiten, Modelle und Feiertage; Rückgabe erlaubt Korrektur", async () => {
const f = await fixture();
for (const date of ["2026-01-05", "2026-01-06", "2026-01-08"])
await f.run(
f.staff,
"time",
time(date, 480, date === "2026-01-05" ? 900 : 840),
);
await f.run(f.staff, "weekSubmit", { weekStart: "2026-01-05" });
await assert.rejects(
f.run(f.staff, "time", time("2026-01-05", 1000, 1100)),
/zuerst.*zurückgeben/,
);
await f.run(f.admin, "weekReview", {
weekStart: "2026-01-05",
approve: true,
reason: "Zeiten geprüft",
});
let p = await getStaffProfile(f.staff);
assert.ok(p);
assert.equal(
getTimeBalance(p, p.weeks, p.adjustments, p.leaves).confirmed,
660,
);
await assert.rejects(
f.run(f.admin, "pattern", {
effectiveFrom: "2026-01-01",
dailyMinutes: [480, 480, 0, 480, 0, 0, 0],
reason: "Änderung",
}),
/bestehen bereits Freigaben/,
);
await assert.rejects(
f.run(f.admin, "holiday", { date: "2026-01-05", name: "Feiertag" }),
/bestehen bereits Freigaben/,
);
await f.run(f.admin, "weekReview", {
weekStart: "2026-01-05",
approve: false,
reason: "Bitte Pause prüfen",
});
p = await getStaffProfile(f.staff);
assert.ok(p);
assert.equal(
getTimeBalance(p, p.weeks, p.adjustments, p.leaves).confirmed,
600,
);
await f.run(f.staff, "time", time("2026-01-05", 1000, 1100));
});
test("Freizeitausgleich bleibt bei Storno reserviert und wird mit der Woche einmal verrechnet", async () => {
const f = await fixture();
await f.run(f.staff, "leave", {
kind: "TIME_OFF",
startDate: "2026-01-05",
endDate: "2026-01-05",
halfDay: false,
minutes: 60,
});
const leave = await prisma.staffLeave.findFirstOrThrow({
where: { profileId: f.profile.id },
});
await f.run(f.admin, "leaveReview", {
id: leave.id,
approve: true,
reason: "Guthaben geprüft",
});
let p = await getStaffProfile(f.staff);
assert.ok(p);
assert.equal(
getTimeBalance(p, p.weeks, p.adjustments, p.leaves).available,
540,
);
for (const date of ["2026-01-05", "2026-01-06", "2026-01-08"])
await f.run(
f.staff,
"time",
time(date, 480, date === "2026-01-05" ? 780 : 840),
);
await f.run(f.staff, "weekSubmit", { weekStart: "2026-01-05" });
await f.run(f.staff, "leaveCancel", { id: leave.id });
await assert.rejects(
f.run(f.admin, "weekReview", {
weekStart: "2026-01-05",
approve: true,
reason: "Geprüft",
}),
/offene Abwesenheiten/,
);
await f.run(f.admin, "leaveReview", {
id: leave.id,
approve: false,
reason: "Stornierung abgelehnt",
});
await f.run(f.admin, "weekReview", {
weekStart: "2026-01-05",
approve: true,
reason: "Geprüft",
});
p = await getStaffProfile(f.staff);
assert.ok(p);
assert.deepEqual(getTimeBalance(p, p.weeks, p.adjustments, p.leaves), {
confirmed: 540,
reserved: 0,
available: 540,
});
});
test("Halbtagsurlaub lässt nur die verbleibende Sollzeit als Arbeitszeit zu", async () => {
const f = await fixture();
await f.run(f.staff, "leave", { ...vacation(), halfDay: true });
const leave = await prisma.staffLeave.findFirstOrThrow({
where: { profileId: f.profile.id },
});
await f.run(f.admin, "leaveReview", {
id: leave.id,
approve: true,
reason: "Halber Tag",
});
await f.run(f.staff, "time", time("2026-01-05", 480, 660));
await assert.rejects(
f.run(f.staff, "time", time("2026-01-05", 700, 760)),
/überschreiten.*Sollzeit/,
);
});
test("Aktivierungslinks verknüpfen nur passende Accounts und werden bei Erneuerung ersetzt", async () => {
const f = await fixture();
const educator = await prisma.educator.create({
data: { kitaId: f.kita.id, firstName: "Neue", lastName: "Fachkraft" },
});
await f.run(f.admin, "setup", {
educatorId: educator.id,
employmentStart: "2026-01-01",
employmentEnd: null,
trackingStart: "2026-01-01",
openingMinutes: 0,
reason: "Einrichtung",
});
const profile = await prisma.staffProfile.findUniqueOrThrow({
where: { educatorId: educator.id },
});
const email = `${randomUUID()}@staff-test.invalid`;
const first = await f.run(f.admin, "invite", {
profileId: profile.id,
email,
});
assert.ok(first.invite);
const second = await f.run(f.admin, "invite", {
profileId: profile.id,
email,
});
assert.ok(second.invite);
assert.equal(
await prisma.verificationToken.count({
where: { token: hashToken(first.invite.token) },
}),
0,
);
const linked = await prisma.staffProfile.findUniqueOrThrow({
where: { id: profile.id },
include: { user: true },
});
assert.equal(linked.user?.role, "ERZIEHER");
assert.equal(linked.user?.passwordHash, "");
await assert.rejects(
f.run(f.admin, "invite", {
profileId: profile.id,
email: f.staffUser.email,
}),
/bereits einen Account/,
);
await prisma.verificationToken.deleteMany({
where: { identifier: linked.userId! },
});
});
test("Krankheit ist adminverwaltet, entlastet Sollzeit und verbraucht keinen Urlaub", async () => {
const f = await fixture();
const input = {
kind: "SICKNESS",
startDate: "2026-01-08",
endDate: "2026-01-08",
halfDay: false,
};
await assert.rejects(f.run(f.staff, "leave", input), /Nur Kita-Admins/);
await f.run(f.admin, "leave", input);
const leave = await prisma.staffLeave.findFirstOrThrow({
where: { profileId: f.profile.id },
include: { days: true },
});
assert.equal(leave.status, "APPROVED");
assert.equal(leave.days[0].creditMinutes, 360);
assert.equal(leave.days[0].vacationUnits, 0);
assert.equal(leave.days[0].date.getTime(), dateOnly("2026-01-08").getTime());
});
test("Beschäftigungsende bleibt nach Erfassungsstart verwaltbar und schützt vorhandene Zeiten", async () => {
const f = await fixture();
await f.run(f.staff, "time", time());
await f.run(f.admin, "employmentEnd", {
employmentEnd: "2026-01-31",
reason: "Vertragsende",
});
await assert.rejects(
f.run(f.staff, "time", time("2026-02-02")),
/innerhalb des Erfassungszeitraums/,
);
await assert.rejects(
f.run(f.admin, "employmentEnd", {
employmentEnd: "2026-01-04",
reason: "Zu früh",
}),
/aktive Anträge/,
);
await f.run(f.admin, "employmentEnd", {
employmentEnd: null,
reason: "Vertrag verlängert",
});
await f.run(f.staff, "time", time("2026-02-02"));
});
test("Personalaufzeichnungen sind vor Fachkraftlöschung geschützt und bleiben nach Accountlöschung erhalten", async () => {
const f = await fixture();
await f.run(f.staff, "time", time());
await assert.rejects(
prisma.educator.delete({ where: { id: f.educator.id } }),
(error: unknown) =>
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "P2003",
);
await prisma.user.delete({ where: { id: f.staff.id } });
const profile = await prisma.staffProfile.findUniqueOrThrow({
where: { id: f.profile.id },
include: { timeEntries: true },
});
assert.equal(profile.userId, null);
assert.equal(profile.timeEntries.length, 1);
});
test("Entzogene Rollen, fehlender Consent und gesperrte Accounts können keine Personalaktionen ausführen", async () => {
const f = await fixture();
await assert.rejects(
f.run({ ...f.staff, role: "ADMIN" }, "account", {
year: 2026,
entitlementUnits: 100,
carryUnits: 0,
reason: "Manipulation",
}),
/keinen Zugriff/,
);
await prisma.user.update({
where: { id: f.staff.id },
data: { privacyPolicyAcceptedAt: null },
});
await assert.rejects(f.run(f.staff, "time", time()), /keinen Zugriff/);
await prisma.user.update({
where: { id: f.staff.id },
data: {
privacyPolicyAcceptedAt: new Date(),
emailVerifiedAt: new Date(),
lockedUntil: new Date(Date.now() + 60000),
},
});
await assert.rejects(f.run(f.staff, "time", time()), /keinen Zugriff/);
});
function onboarding(email = `${randomUUID()}@staff-test.invalid`) {
return {
firstName: " Neue ",
lastName: " Fachkraft ",
active: true,
email,
employmentStart: "2024-08-01",
trackingStart: "2026-10-09",
};
}
test("Gemeinsame Anlage speichert Fachkraft, Profil, Account und wiederholbaren Mailauftrag", async () => {
const f = await fixture();
const input = onboarding();
const result = await executeStaffCommand(f.admin, {
command: "educatorCreate",
data: input,
});
const educator = await prisma.educator.findUniqueOrThrow({
where: { id: result.educatorId },
include: { staffProfile: { include: { user: true } } },
});
assert.equal(educator.firstName, "Neue");
assert.equal(educator.kitaId, f.kita.id);
assert.equal(educator.staffProfile?.user?.email, input.email);
assert.equal(educator.staffProfile?.user?.emailVerifiedAt, null);
assert.equal(educator.staffProfile?.user?.role, "ERZIEHER");
assert.equal(
educator.staffProfile?.employmentStart.toISOString().slice(0, 10),
"2024-08-01",
);
assert.equal(
educator.staffProfile?.trackingStart.toISOString().slice(0, 10),
"2026-10-09",
);
assert.equal(educator.staffProfile?.openingMinutes, 0);
assert.ok(result.invite);
assert.ok(
await prisma.verificationToken.findUnique({
where: { token: hashToken(result.invite.token) },
}),
);
assert.equal(
(
await deliverStaffEmails(result.emailIds!, async () => ({
success: false,
error: "Testausfall",
}))
).failed,
1,
);
assert.ok(await prisma.educator.findUnique({ where: { id: educator.id } }));
const retry = await executeStaffCommand(f.admin, {
command: "emailRetry",
data: {},
});
assert.deepEqual(retry.emailIds, result.emailIds);
assert.equal(
(
await deliverStaffEmails(retry.emailIds!, async () => ({
success: true,
id: "mock",
}))
).sent,
1,
);
});
test("Pflichtfelder und Erfassungsdatum verhindern unvollständige oder falsche Anlagen", async () => {
const f = await fixture();
const before = await prisma.educator.count({ where: { kitaId: f.kita.id } });
for (const input of [
onboarding(""),
{ ...onboarding(), firstName: " " },
{ ...onboarding(), trackingStart: "2024-07-31" },
{ ...onboarding(), employmentStart: "2026-02-30" },
]) {
await assert.rejects(
executeStaffCommand(f.admin, { command: "educatorCreate", data: input }),
);
}
assert.equal(
await prisma.educator.count({ where: { kitaId: f.kita.id } }),
before,
);
});
test("Doppelte oder fremde Accounts rollen die gesamte Anlage zurück", async () => {
const f = await fixture(),
foreign = await fixture();
const parent = await prisma.user.findUniqueOrThrow({
where: { id: f.parent.id },
});
const before = await prisma.educator.count({ where: { kitaId: f.kita.id } });
for (const email of [
f.staffUser.email,
foreign.staffUser.email,
parent.email,
]) {
await assert.rejects(
executeStaffCommand(f.admin, {
command: "educatorCreate",
data: onboarding(email),
}),
/bereits/,
);
}
assert.equal(
await prisma.educator.count({ where: { kitaId: f.kita.id } }),
before,
);
});
test("Freier aktivierter Fachkraft-Account derselben Kita wird verknüpft", async () => {
const f = await fixture();
const user = await prisma.user.update({
where: { id: f.other.id },
data: { emailVerifiedAt: new Date() },
});
const result = await executeStaffCommand(f.admin, {
command: "educatorCreate",
data: onboarding(user.email.toUpperCase()),
});
const profile = await prisma.staffProfile.findUniqueOrThrow({
where: { educatorId: result.educatorId },
});
assert.equal(profile.userId, user.id);
assert.equal(result.invite, undefined);
assert.equal(result.emailIds, undefined);
});
test("Bestehende Fachkraft ohne Profil erhält Zugang im Bearbeitungsablauf", async () => {
const f = await fixture();
const educator = await prisma.educator.create({
data: { firstName: "Alt", lastName: "Ohne Profil", kitaId: f.kita.id },
});
const input = onboarding();
await assert.rejects(
executeStaffCommand(f.admin, {
command: "educatorUpdate",
data: { ...input, educatorId: educator.id, email: f.staffUser.email },
}),
/bereits/,
);
assert.equal(
(await prisma.educator.findUniqueOrThrow({ where: { id: educator.id } }))
.firstName,
"Alt",
);
const result = await executeStaffCommand(f.admin, {
command: "educatorUpdate",
data: { ...input, educatorId: educator.id },
});
assert.equal(result.educatorId, educator.id);
assert.ok(result.invite);
assert.equal(
await prisma.staffProfile.count({ where: { educatorId: educator.id } }),
1,
);
});
test("Bearbeiten schützt verknüpfte Identität, Zeiten und Konten", async () => {
const f = await fixture();
await assert.rejects(
executeStaffCommand(f.admin, {
command: "educatorUpdate",
data: {
...onboarding(),
educatorId: f.educator.id,
},
}),
/nicht geändert/,
);
await executeStaffCommand(f.admin, {
command: "educatorUpdate",
data: {
...onboarding(f.staffUser.email),
firstName: "Korrigiert",
educatorId: f.educator.id,
employmentStart: "2025-01-01",
trackingStart: "2025-01-01",
},
});
const profile = await getStaffProfile(f.admin, f.profile.id);
assert.equal(profile?.openingMinutes, 600);
assert.equal(
profile?.employmentStart.toISOString().slice(0, 10),
"2026-01-01",
);
assert.equal(profile?.workPatterns.length, 1);
assert.equal(profile?.leaveAccounts.length, 1);
assert.equal(profile?.user?.firstName, "Korrigiert");
});
test("Einladung aus Teamliste erneuert nur eigene ausstehende Aktivierung", async () => {
const f = await fixture(),
foreign = await fixture();
const result = await executeStaffCommand(f.admin, {
command: "educatorCreate",
data: onboarding(),
});
for (const actor of [f.staff, f.parent, foreign.admin]) {
await assert.rejects(
executeStaffCommand(actor, {
command: "educatorInvite",
data: { educatorId: result.educatorId },
}),
);
}
const renewed = await executeStaffCommand(f.admin, {
command: "educatorInvite",
data: { educatorId: result.educatorId },
});
assert.ok(renewed.invite);
assert.notEqual(renewed.invite.token, result.invite?.token);
assert.equal(
await prisma.verificationToken.count({
where: { token: hashToken(result.invite!.token) },
}),
0,
);
const profile = await prisma.staffProfile.findUniqueOrThrow({
where: { educatorId: result.educatorId },
});
await prisma.user.update({
where: { id: profile.userId! },
data: { emailVerifiedAt: new Date() },
});
await assert.rejects(
executeStaffCommand(f.admin, {
command: "educatorInvite",
data: { educatorId: result.educatorId },
}),
/bereits einen Account/,
);
});
test("Nur aktuelle Kita-Admins dürfen Fachkräfte anlegen und bearbeiten", async () => {
const f = await fixture();
const before = await prisma.educator.count({ where: { kitaId: f.kita.id } });
for (const actor of [
f.staff,
f.parent,
{ ...f.staff, role: UserRole.ADMIN },
]) {
await assert.rejects(
executeStaffCommand(actor, {
command: "educatorCreate",
data: onboarding(),
}),
);
}
const foreign = await fixture();
await assert.rejects(
executeStaffCommand(foreign.admin, {
command: "educatorUpdate",
data: {
...onboarding(f.staffUser.email),
educatorId: f.educator.id,
},
}),
/nicht gefunden/,
);
assert.equal(
await prisma.educator.count({ where: { kitaId: f.kita.id } }),
before,
);
});