Add educator leave management and working time accounts
This commit is contained in:
@@ -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,518 @@
|
||||
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";
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
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/);
|
||||
});
|
||||
Reference in New Issue
Block a user