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 = {}, ) => 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, ); });