981 lines
29 KiB
TypeScript
981 lines
29 KiB
TypeScript
import { after, test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
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 !== "55439" ||
|
|
db.pathname !== "/kita_staff_test"
|
|
)
|
|
throw new Error(
|
|
"Integrationstests benötigen die isolierte Datenbank 127.0.0.1:55439/kita_staff_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(),
|
|
},
|
|
});
|
|
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(),
|
|
},
|
|
});
|
|
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: 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(),
|
|
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: 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: 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,
|
|
);
|
|
});
|