import assert from "node:assert/strict"; import { mock, test } from "node:test"; import { createRequire } from "node:module"; import { resolve } from "node:path"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { PrismaClient, UserRole } from "@prisma/client"; import { absenceDate, absenceToday } from "../src/lib/absence-policy"; test( "Familieneinladungen und Abwesenheiten gegen isoliertes 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)); assert.ok( /test/.test(url.pathname), "Nur ausdrücklich bezeichnete Testdatenbanken", ); process.env.DATABASE_URL = url.toString(); process.env.NEXT_PUBLIC_SITE_URL = "http://127.0.0.1:3010"; const db = new PrismaClient(); const suffix = randomUUID(); const kitaId = `flow-test-${suffix}`, otherKitaId = `other-flow-${suffix}`; let actor: { id: string; kitaId: string; familyId: string | null; role: UserRole; }; const deliveries: { to: string; key: string | undefined }[] = []; const fail = new Set(); let throwTransport = false; const refreshed: string[] = []; const moduleMock = mock as typeof mock & { module: ( name: string, options: { namedExports: Record }, ) => unknown; }; moduleMock.module("../src/lib/auth-utils.ts", { namedExports: { requireKitaSession: async () => ({ user: actor }), requireRole: async (roles: UserRole[]) => { assert.ok(roles.includes(actor.role), "Rolle muss erlaubt sein"); return { user: actor }; }, }, }); moduleMock.module("next/cache", { namedExports: { revalidatePath: (path: string) => refreshed.push(path) }, }); moduleMock.module("../src/auth.ts", { namedExports: { signIn: async () => {} }, }); moduleMock.module("../src/lib/mail.ts", { namedExports: { sendAppEmail: async (input: { to: string; idempotencyKey?: string; }) => { deliveries.push({ to: input.to, key: input.idempotencyKey }); if (throwTransport) throw new Error("Simulierter Transportfehler"); return fail.has(input.to) ? { success: false, error: "Simulierter Mailfehler" } : { success: true, id: "test-only" }; }, getAppEmailConfigError: () => "Test ohne echte Mail-Konfiguration", }, }); const load = createRequire( resolve("tests/family-absence-flows.integration.test.ts"), ); const familyActions = load( "../src/app/dashboard/families/actions", ) as typeof import("../src/app/dashboard/families/actions"); const absences = load( "../src/actions/absences", ) as typeof import("../src/actions/absences"); const { acceptInviteAction } = load( "../src/app/invite/[token]/actions", ) as typeof import("../src/app/invite/[token]/actions"); const { prisma } = load( "../src/lib/prisma", ) as typeof import("../src/lib/prisma"); const today = absenceToday(); const day = (offset: number) => new Date(absenceDate(today).getTime() + offset * 86_400_000) .toISOString() .slice(0, 10); const actorOf = (user: { id: string; kitaId: string | null; familyId: string | null; role: UserRole; }) => ({ ...user, kitaId: user.kitaId! }); const createUser = ( role: UserRole, tenant = kitaId, familyId: string | null = null, ) => db.user.create({ data: { kitaId: tenant, familyId, role, email: `${randomUUID()}@example.test`, passwordHash: "", firstName: "Test", lastName: role, privacyPolicyAcceptedAt: new Date(), }, }); const form = (name: string, emails: string[]) => { const data = new FormData(); for (const [key, value] of Object.entries({ familyName: name, parent1FirstName: "Eva", parent1LastName: "Test", parent1Email: emails[0], childCount: "1", childFirstName_0: "Kind", childLastName_0: "Test", })) data.set(key, value); if (emails[1]) for (const [key, value] of Object.entries({ parent2FirstName: "Jan", parent2LastName: "Test", parent2Email: emails[1], })) data.set(key, value); return data; }; const createAbsence = ( childId: string, start: number, end = start, tenant = kitaId, ) => db.absence.create({ data: { kitaId: tenant, childId, startDate: absenceDate(day(start)), endDate: absenceDate(day(end)), reason: "ILLNESS", }, }); const identity = (row: { id: string; updatedAt: Date }) => ({ id: row.id, updatedAt: row.updatedAt.toISOString(), }); try { await db.kita.createMany({ data: [ { id: kitaId, name: "Ablauftest", slug: kitaId }, { id: otherKitaId, name: "Andere Kita", slug: otherKitaId }, ], }); const admin = await createUser("ADMIN"); actor = actorOf(admin); const emails = [ `failure-${suffix}@example.test`, `success-${suffix}@example.test`, ]; fail.add(emails[0]); let invitedUsers: Awaited> = []; await t.test( "Mailfehler lässt Familie erfolgreich gespeichert; alle Empfänger werden versucht", async () => { const result = await familyActions.addFamilyAction( {}, form("Eingeladen", emails), ); assert.equal(result.success, true); assert.ok(result.warning); assert.equal(result.errors, undefined); assert.equal( await db.family.count({ where: { kitaId, name: "Eingeladen" } }), 1, ); assert.ok(refreshed.includes("/dashboard/families")); invitedUsers = await db.user.findMany({ where: { email: { in: emails } }, }); assert.equal(invitedUsers.length, 2); const jobs = await db.invitation.findMany({ where: { userId: { in: invitedUsers.map((user) => user.id) } }, }); assert.equal(jobs.length, 2); assert.equal( jobs.find((job) => job.email === emails[0])!.failed, true, ); assert.ok(jobs.find((job) => job.email === emails[1])!.sentAt); assert.equal(deliveries.length, 2); const repeated = await familyActions.addFamilyAction( {}, form("Eingeladen", emails), ); assert.ok(repeated.errors); assert.equal( await db.family.count({ where: { kitaId, name: "Eingeladen" } }), 1, ); }, ); await t.test( "Gezielte Wiederholung behält den gültigen fehlgeschlagenen Link und erstellt keine Person erneut", async () => { const user = invitedUsers.find((user) => user.email === emails[0])!; const before = await db.invitation.findUniqueOrThrow({ where: { userId: user.id }, }); fail.clear(); const result = await familyActions.resendFamilyInvitationAction( user.id, ); assert.equal(result.success, true); assert.equal(result.warning, undefined); const after = await db.invitation.findUniqueOrThrow({ where: { userId: user.id }, }); assert.equal(after.token, before.token); assert.equal(after.attempts, 2); assert.equal(after.failed, false); assert.ok(after.sentAt); assert.equal(deliveries.length, 3); assert.equal(deliveries[2].key, deliveries[0].key); assert.equal( await db.user.count({ where: { email: user.email } }), 1, ); const again = await familyActions.resendFamilyInvitationAction( user.id, ); assert.ok(again.error); assert.equal(deliveries.length, 3); }, ); await t.test( "Abgelaufener Link wird erneuert und alter Token ungültig", async () => { const user = invitedUsers[0]; const before = await db.invitation.update({ where: { userId: user.id }, data: { expiresAt: absenceDate(day(-1)), sentAt: absenceDate(day(-2)), }, }); await db.verificationToken.update({ where: { token: before.token }, data: { expires: absenceDate(day(-1)) }, }); assert.equal( (await familyActions.resendFamilyInvitationAction(user.id)).success, true, ); const after = await db.invitation.findUniqueOrThrow({ where: { userId: user.id }, }); assert.notEqual(after.token, before.token); assert.ok(after.expiresAt > new Date()); assert.equal( await db.verificationToken.findUnique({ where: { token: before.token }, }), null, ); }, ); await t.test( "Aktivierte und kita-fremde Konten werden nicht angeschrieben", async () => { const user = invitedUsers[0]; await db.user.update({ where: { id: user.id }, data: { emailVerifiedAt: new Date() }, }); const before = deliveries.length; assert.ok( (await familyActions.resendFamilyInvitationAction(user.id)).error, ); const foreignFamily = await db.family.create({ data: { kitaId: otherKitaId, name: "Fremd" }, }); const foreign = await createUser( "ELTERN", otherKitaId, foreignFamily.id, ); assert.ok( (await familyActions.resendFamilyInvitationAction(foreign.id)) .error, ); assert.equal(deliveries.length, before); }, ); await t.test( "Hinzufügen eines Elternteils bei Mailfehler ist abgeschlossen und wiederholbar", async () => { const first = `${randomUUID()}@example.test`; assert.equal( (await familyActions.addFamilyAction({}, form("Ergänzen", [first]))) .success, true, ); const family = await db.family.findFirstOrThrow({ where: { kitaId, name: "Ergänzen" }, include: { users: true, children: true }, }); const second = `${randomUUID()}@example.test`; fail.add(second); const result = await familyActions.updateFamilyAction({ familyId: family.id, familyName: family.name, parents: family.users, children: family.children, newParents: [{ firstName: "Neu", lastName: "Test", email: second }], }); assert.equal(result.success, true); assert.ok(result.warning); assert.equal(result.error, undefined); assert.equal( await db.user.count({ where: { familyId: family.id } }), 2, ); fail.clear(); const parent = await db.user.findUniqueOrThrow({ where: { email: second }, }); assert.equal( (await familyActions.resendFamilyInvitationAction(parent.id)) .success, true, ); }, ); await t.test( "Adresskorrektur eines offenen Kontos erneuert die Einladung für die neue Adresse", async () => { const family = await db.family.findFirstOrThrow({ where: { kitaId, name: "Eingeladen" }, include: { users: true, children: true }, }); const pending = family.users.find((user) => !user.emailVerifiedAt)!; const old = await db.invitation.findUniqueOrThrow({ where: { userId: pending.id }, }); const email = `${randomUUID()}@example.test`; const result = await familyActions.updateFamilyAction({ familyId: family.id, familyName: family.name, parents: family.users.map((user) => ({ ...user, email: user.id === pending.id ? email : user.email, })), children: family.children, }); assert.equal(result.success, true); const renewed = await db.invitation.findUniqueOrThrow({ where: { userId: pending.id }, }); assert.equal(renewed.email, email); assert.notEqual(renewed.token, old.token); assert.equal( await db.verificationToken.findUnique({ where: { token: old.token }, }), null, ); assert.equal(deliveries.at(-1)!.to, email); }, ); await t.test( "Transportausnahme wird gespeichert statt den Familienvorgang zurückzuwerfen", async () => { throwTransport = true; const result = await familyActions.addFamilyAction( {}, form("Transportfehler", [`${randomUUID()}@example.test`]), ); assert.equal(result.success, true); assert.ok(result.warning); throwTransport = false; }, ); await t.test( "Alte Einladungen werden übernommen; paralleles erneutes Senden verschickt nur einmal und Aktivierung schließt den Status ab", async () => { const household = await db.family.create({ data: { kitaId, name: "Alte Einladung" }, }); const user = await createUser("ELTERN", kitaId, household.id); const oldToken = randomUUID(); await db.verificationToken.create({ data: { identifier: user.id, token: oldToken, expires: absenceDate(day(-1)), }, }); await db.invitation.create({ data: { kitaId, email: user.email, token: oldToken, expiresAt: absenceDate(day(-1)), }, }); const before = deliveries.length; const results = await Promise.all([ familyActions.resendFamilyInvitationAction(user.id), familyActions.resendFamilyInvitationAction(user.id), ]); assert.ok(results.some((result) => result.success)); assert.equal(deliveries.length, before + 1); assert.equal( await db.invitation.count({ where: { userId: user.id } }), 1, ); const job = await db.invitation.findUniqueOrThrow({ where: { userId: user.id }, }); assert.equal( await db.verificationToken.findUnique({ where: { token: oldToken }, }), null, ); const activation = new FormData(); activation.set("token", job.token); activation.set("password", "Test-only-password-2026"); activation.set("confirmPassword", "Test-only-password-2026"); activation.set("acceptPrivacyPolicy", "on"); await acceptInviteAction({ attempt: 0 }, activation); assert.ok( (await db.user.findUniqueOrThrow({ where: { id: user.id } })) .emailVerifiedAt, ); assert.equal( ( await db.invitation.findUniqueOrThrow({ where: { userId: user.id }, }) ).status, "ACCEPTED", ); assert.equal( await db.verificationToken.findUnique({ where: { token: job.token }, }), null, ); }, ); const ownFamily = await db.family.create({ data: { kitaId, name: "Eigener Haushalt" }, }); const otherFamily = await db.family.create({ data: { kitaId, name: "Anderer Haushalt" }, }); const parent = await createUser("ELTERN", kitaId, ownFamily.id); const child = await db.child.create({ data: { kitaId, familyId: ownFamily.id, firstName: "Eigen", lastName: "Kind", }, }); const otherChild = await db.child.create({ data: { kitaId, familyId: otherFamily.id, firstName: "Fremd", lastName: "Kind", }, }); actor = actorOf(parent); let future = await createAbsence(child.id, 3, 4); await t.test( "Eltern bearbeiten zukünftige Meldungen und die eigene Tagesgrenze wird eingehalten", async () => { const result = await absences.updateAbsence({ ...identity(future), startDate: day(5), endDate: day(6), reason: "VACATION", note: "Korrigiert", }); assert.equal(result.success, true); future = await db.absence.findUniqueOrThrow({ where: { id: future.id }, }); assert.equal(future.startDate.toISOString().slice(0, 10), day(5)); assert.equal(future.note, "Korrigiert"); assert.ok(refreshed.includes("/dashboard/abwesenheiten")); assert.ok( ( await absences.updateAbsence({ ...identity(future), startDate: today, endDate: day(6), reason: "VACATION", }) ).error, ); }, ); await t.test( "Begonnene Mehrtagesmeldungen bleiben für Eltern vollständig unveränderbar", async () => { const running = await createAbsence(child.id, -1, 1); assert.ok( ( await absences.updateAbsence({ ...identity(running), startDate: day(1), endDate: day(2), reason: "OTHER", }) ).error, ); assert.ok((await absences.deleteAbsence(identity(running))).error); assert.equal( await db.absence.count({ where: { id: running.id } }), 1, ); }, ); await t.test( "Andere Haushalte und Kitas bleiben bei Änderungen und Rücknahmen geschützt", async () => { const other = await createAbsence(otherChild.id, 10); assert.ok((await absences.deleteAbsence(identity(other))).error); assert.ok( ( await absences.updateAbsence({ ...identity(other), startDate: day(11), endDate: day(11), reason: "OTHER", }) ).error, ); actor = { ...actor, kitaId: otherKitaId }; assert.ok((await absences.deleteAbsence(identity(future))).error); actor = actorOf(parent); await assert.rejects(() => absences.getDailyAbsences({ date: today }), ); }, ); await t.test( "Ungültige Daten, Überschneidungen und veraltete Bearbeitungsstände werden abgewiesen", async () => { await createAbsence(child.id, 8, 9); assert.ok( ( await absences.updateAbsence({ ...identity(future), startDate: day(8), endDate: day(8), reason: "OTHER", }) ).error, ); assert.ok( ( await absences.updateAbsence({ ...identity(future), startDate: "2026-02-30", endDate: day(8), reason: "OTHER", }) ).error, ); assert.ok( ( await absences.updateAbsence({ ...identity(future), startDate: day(7), endDate: day(6), reason: "OTHER", }) ).error, ); assert.ok( ( await absences.deleteAbsence({ id: future.id, updatedAt: "2000-01-01T00:00:00.000Z", }) ).error, ); assert.equal(await db.absence.count({ where: { id: future.id } }), 1); }, ); await t.test( "Gleichzeitige Abmeldungen erzeugen höchstens eine überlappende Meldung", async () => { const payload = { childId: child.id, startDate: day(20), endDate: day(22), reason: "ILLNESS", }; const results = await Promise.all([ absences.reportAbsence(payload), absences.reportAbsence(payload), ]); assert.equal(results.filter((result) => result.success).length, 1); assert.equal(results.filter((result) => result.error).length, 1); assert.equal( await db.absence.count({ where: { childId: child.id, startDate: absenceDate(day(20)) }, }), 1, ); assert.ok( ( await absences.reportAbsence({ ...payload, startDate: day(-2), endDate: day(-2), }) ).error, ); assert.ok( ( await absences.reportAbsence({ ...payload, childId: otherChild.id, }) ).error, ); }, ); await t.test( "Kita-Team korrigiert laufende Meldungen ohne eigenen Haushalt; Eltern ziehen zukünftige zurück", async () => { const running = await db.absence.findFirstOrThrow({ where: { childId: child.id, startDate: absenceDate(day(-1)) }, }); for (const role of [ "ADMIN", "KOORDINATOR", "ERZIEHER", ] as UserRole[]) { const team = role === "ADMIN" ? admin : await createUser(role); actor = actorOf(team); const latest = await db.absence.findUniqueOrThrow({ where: { id: running.id }, }); assert.equal( ( await absences.updateAbsence({ ...identity(latest), startDate: day(-1), endDate: day(2), reason: "OTHER", note: role, }) ).success, true, ); assert.equal( (await absences.getDailyAbsences({ date: today })).absences![0] .startDate, day(-1), ); } actor = actorOf(parent); assert.equal( (await absences.deleteAbsence(identity(future))).success, true, ); assert.equal( await db.absence.findUnique({ where: { id: future.id } }), null, ); }, ); await t.test( "Ein veraltetes Rollen-Token verleiht keine Teamrechte", async () => { actor = { ...actorOf(parent), role: "ADMIN" }; const running = await db.absence.findFirstOrThrow({ where: { childId: child.id, startDate: absenceDate(day(-1)) }, }); assert.ok((await absences.deleteAbsence(identity(running))).error); assert.ok( ( await familyActions.resendFamilyInvitationAction( invitedUsers[1].id, ) ).error, ); }, ); await t.test( "Additives Upgrade funktioniert auf leerem und bestehendem Schema und ist wiederholbar", async () => { const sql = await readFile( resolve("deploy/sql/familieneinladungen.sql"), "utf8", ); const schema = `upgrade_test_${suffix.replaceAll("-", "")}`; await db.$executeRawUnsafe(`CREATE SCHEMA "${schema}"`); try { await db.$transaction(async (tx) => { await tx.$executeRawUnsafe( `SET LOCAL search_path TO "${schema}"`, ); await tx.$executeRawUnsafe(sql); const fresh = await tx.$queryRawUnsafe< { relation: string | null }[] >("SELECT to_regclass('invitations')::text AS relation"); assert.equal(fresh[0].relation, null); await tx.$executeRawUnsafe( 'CREATE TABLE "users" ("id" TEXT PRIMARY KEY)', ); await tx.$executeRawUnsafe( 'CREATE TABLE "invitations" ("id" TEXT PRIMARY KEY, "token" TEXT NOT NULL)', ); await tx.$executeRawUnsafe( "INSERT INTO \"invitations\" (\"id\", \"token\") VALUES ('a', 'old-a'), ('b', 'old-b')", ); await tx.$executeRawUnsafe(sql); await tx.$executeRawUnsafe(sql); const rows = await tx.$queryRawUnsafe< { token: string; userId: string | null; failed: boolean; attempts: number; }[] >( 'SELECT "token", "userId", "failed", "attempts" FROM "invitations" ORDER BY "id"', ); assert.deepEqual(rows, [ { token: "old-a", userId: null, failed: false, attempts: 0 }, { token: "old-b", userId: null, failed: false, attempts: 0 }, ]); }); } finally { await db.$executeRawUnsafe(`DROP SCHEMA "${schema}" CASCADE`); } }, ); } finally { const users = await db.user.findMany({ where: { kitaId: { in: [kitaId, otherKitaId] } }, select: { id: true }, }); await db.verificationToken.deleteMany({ where: { identifier: { in: users.map((user) => user.id) } }, }); await db.kita.deleteMany({ where: { id: { in: [kitaId, otherKitaId] } }, }); await Promise.all([db.$disconnect(), prisma.$disconnect()]); } }, );