Merge current features with security hardening and automatic safe initialization
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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' } }));
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user