import { strict as assert } from "node:assert"; import { mock, test } from "node:test"; import { createRequire } from "node:module"; import { resolve } from "node:path"; import { randomUUID } from "node:crypto"; import { AnnouncementAudience, EventParticipationStatus as S, PrismaClient, RsvpMode, TerminStatus, TerminType, UserRole, } from "@prisma/client"; // No database work without an explicitly supplied local test database. test( "Elternabläufe gegen PostgreSQL", { skip: !process.env.TEST_DATABASE_URL }, async (t) => { const url = new URL(process.env.TEST_DATABASE_URL!); assert.ok( ["127.0.0.1", "localhost"].includes(url.hostname), "Nur lokale Testdatenbanken sind erlaubt", ); assert.ok( /test|parent_flows/.test(url.pathname), "Der Datenbankname muss ausdrücklich eine Testdatenbank kennzeichnen", ); process.env.DATABASE_URL = url.toString(); const db = new PrismaClient(); const id = randomUUID(); const kitaId = `parent-test-${id}`; const otherKitaId = `other-test-${id}`; let user = { id: `parent-a-${id}`, kitaId, familyId: `family-${id}`, role: UserRole.ELTERN as UserRole, }; const moduleMock = mock as typeof mock & { module: ( specifier: string, options: { namedExports: Record }, ) => unknown; }; moduleMock.module("../src/lib/auth-utils.ts", { namedExports: { requireKitaSession: async () => ({ user }), getKitaSession: async () => ({ user }), requireRole: async (roles: UserRole[]) => { assert.ok(roles.includes(user.role)); return { user }; }, }, }); moduleMock.module("../src/auth.ts", { namedExports: { auth: async () => ({ user }) }, }); moduleMock.module("next/cache", { namedExports: { revalidatePath: () => {} }, }); const loadAfterMocks = createRequire( resolve("tests/parent-flows.integration.test.ts"), ); const { submitEventRsvp } = loadAfterMocks( "../src/actions/rsvp", ) as typeof import("../src/actions/rsvp"); const { updateTerminAdmin } = loadAfterMocks( "../src/app/dashboard/kalender/actions", ) as typeof import("../src/app/dashboard/kalender/actions"); const { createAnnouncement, markAnnouncementRead } = loadAfterMocks( "../src/actions/announcements", ) as typeof import("../src/actions/announcements"); const { GET: getAttachment } = loadAfterMocks( "../src/app/api/announcements/attachments/[id]/route", ) as typeof import("../src/app/api/announcements/attachments/[id]/route"); const familyId = user.familyId; const startDate = new Date(Date.now() + 86400000 * 3); const endDate = new Date(startDate.getTime() + 3600000); const eventBase = { kitaId, title: "Testtermin", type: TerminType.SONSTIGES, status: TerminStatus.CONFIRMED, startDate, endDate, requiresRsvp: true, }; const updateBase = { title: "Testtermin geändert", type: TerminType.SONSTIGES, startDate: startDate.toISOString(), endDate: endDate.toISOString(), }; let teacherAnnouncementId = ""; try { await db.kita.create({ data: { id: kitaId, name: "Elternabläufe Test", slug: kitaId }, }); await db.kita.create({ data: { id: otherKitaId, name: "Andere Kita", slug: otherKitaId }, }); await db.family.create({ data: { id: familyId, kitaId, name: "Testfamilie" }, }); await db.family.create({ data: { id: `other-family-${id}`, kitaId: otherKitaId, name: "Fremde Familie", }, }); await db.user.create({ data: { id: user.id, kitaId, familyId, email: `${id}@example.test`, passwordHash: "test", firstName: "Test", lastName: "Eltern", }, }); const children = await Promise.all( ["a", "b"].map((suffix) => db.child.create({ data: { id: `${suffix}-${id}`, kitaId, familyId, firstName: suffix, lastName: "Test", }, }), ), ); const foreign = await db.child.create({ data: { kitaId: otherKitaId, familyId: `other-family-${id}`, firstName: "Fremd", lastName: "Test", }, }); const inactive = await db.child.create({ data: { kitaId, familyId, firstName: "Inaktiv", lastName: "Test", active: false, }, }); const familyEvent = await db.termin.create({ data: { ...eventBase, rsvpMode: RsvpMode.FAMILY }, }); const childEvent = await db.termin.create({ data: { ...eventBase, rsvpMode: RsvpMode.CHILD, mitbringselListEnabled: true, participantInfo: "Nur Teilnehmende", }, }); const familyPayload = { eventId: familyEvent.id, mode: RsvpMode.FAMILY, status: S.ATTENDING, adultCount: 2, }; const childPayload = { eventId: childEvent.id, mode: RsvpMode.CHILD, responses: [ { childId: children[0].id, status: S.ATTENDING }, { childId: children[1].id, status: S.NOT_ATTENDING }, ], }; await t.test( "Zwei Elternkonten ändern eine gemeinsame Familienantwort", async () => { assert.equal((await submitEventRsvp(familyPayload)).success, true); user = { ...user, id: `parent-b-${id}` }; assert.equal( (await submitEventRsvp({ ...familyPayload, adultCount: 3 })) .success, true, ); assert.equal( await db.familyEventParticipation.count({ where: { eventId: familyEvent.id }, }), 1, ); assert.equal( ( await db.familyEventParticipation.findFirstOrThrow({ where: { eventId: familyEvent.id }, }) ).adultCount, 3, ); assert.ok( (await submitEventRsvp({ ...familyPayload, adultCount: 0 })).error, ); assert.equal( ( await submitEventRsvp({ ...familyPayload, status: S.NOT_ATTENDING, adultCount: 0, }) ).success, true, ); assert.equal( ( await db.familyEventParticipation.findFirstOrThrow({ where: { eventId: familyEvent.id }, }) ).adultCount, 0, ); }, ); await t.test( "Alle Kinder werden gemeinsam gespeichert, fremde/inaktive Kinder abgewiesen", async () => { assert.equal((await submitEventRsvp(childPayload)).success, true); for (const childId of [foreign.id, inactive.id]) { assert.ok( ( await submitEventRsvp({ ...childPayload, responses: [ { childId: children[0].id, status: S.NOT_ATTENDING }, { childId, status: S.ATTENDING }, ], }) ).error, ); } assert.ok( ( await submitEventRsvp({ ...childPayload, responses: [childPayload.responses[0]], }) ).error, ); const rows = await db.eventParticipation.findMany({ where: { eventId: childEvent.id }, orderBy: { childId: "asc" }, }); assert.equal(rows.length, 2); assert.equal(rows[0].status, S.ATTENDING); assert.equal(rows[1].status, S.NOT_ATTENDING); }, ); await t.test( "Falsche Antwortart und fremde Kita können nicht beschrieben werden", async () => { assert.ok( ( await submitEventRsvp({ ...familyPayload, eventId: childEvent.id, }) ).error, ); user = { ...user, kitaId: otherKitaId }; assert.ok((await submitEventRsvp(familyPayload)).error); user = { ...user, kitaId }; }, ); await t.test( "Frist, Absage und deaktiviertes Terminmodul verhindern Antworten", async () => { await db.termin.update({ where: { id: familyEvent.id }, data: { rsvpDeadline: new Date(Date.now() - 1000) }, }); assert.ok((await submitEventRsvp(familyPayload)).error); await db.termin.update({ where: { id: familyEvent.id }, data: { rsvpDeadline: null, status: TerminStatus.CANCELLED }, }); assert.ok((await submitEventRsvp(familyPayload)).error); await db.termin.update({ where: { id: familyEvent.id }, data: { status: TerminStatus.CONFIRMED }, }); await db.kita.update({ where: { id: kitaId }, data: { terminModuleEnabled: false }, }); assert.ok((await submitEventRsvp(familyPayload)).error); await db.kita.update({ where: { id: kitaId }, data: { terminModuleEnabled: true }, }); }, ); await t.test( "Normale Terminänderung erhält RSVP-Felder; Antwortart ist nach Antwort gesperrt", async () => { user = { ...user, role: UserRole.ADMIN }; assert.equal( (await updateTerminAdmin(childEvent.id, updateBase)).success, true, ); const event = await db.termin.findUniqueOrThrow({ where: { id: childEvent.id }, }); assert.equal(event.requiresRsvp, true); assert.equal(event.mitbringselListEnabled, true); assert.equal(event.participantInfo, "Nur Teilnehmende"); assert.ok( ( await updateTerminAdmin(childEvent.id, { ...updateBase, rsvpMode: RsvpMode.FAMILY, }) ).error, ); }, ); await t.test( "Gleichzeitige erste Antwort und Modusänderung bleiben widerspruchsfrei", async () => { const event = await db.termin.create({ data: { ...eventBase, rsvpMode: RsvpMode.FAMILY }, }); await Promise.all([ submitEventRsvp({ ...familyPayload, eventId: event.id }), updateTerminAdmin(event.id, { ...updateBase, rsvpMode: RsvpMode.CHILD, }), ]); const stored = await db.termin.findUniqueOrThrow({ where: { id: event.id }, include: { familyParticipations: true }, }); assert.ok( stored.rsvpMode === RsvpMode.FAMILY ? stored.familyParticipations.length === 1 : stored.familyParticipations.length === 0, ); }, ); await t.test( "Empfängergruppe wird auch ohne Mail gespeichert", async () => { user = { ...user, id: `parent-a-${id}`, role: UserRole.ADMIN }; const form = new FormData(); form.set("title", "Nur ErzieherInnen"); form.set("content", "Interne Teammitteilung"); form.set("audience", "ERZIEHER"); assert.equal((await createAnnouncement(form)).success, true); const announcement = await db.announcement.findFirstOrThrow({ where: { kitaId, title: "Nur ErzieherInnen" }, }); assert.equal(announcement.audience, AnnouncementAudience.ERZIEHER); teacherAnnouncementId = announcement.id; await db.attachment.create({ data: { id: `teacher-attachment-${id}`, announcementId: announcement.id, fileName: "team.pdf", fileUrl: `/api/announcements/attachments/teacher-attachment-${id}`, fileType: "application/pdf", }, }); }, ); await t.test( "Eltern können Teammitteilungen weder als gelesen markieren noch Anhänge abrufen", async () => { user = { ...user, role: UserRole.ELTERN }; assert.ok((await markAnnouncementRead(teacherAnnouncementId)).error); const response = await getAttachment( new Request("http://localhost/test"), { params: Promise.resolve({ id: `teacher-attachment-${id}` }) }, ); assert.equal(response.status, 404); assert.equal( await db.announcementRead.count({ where: { announcementId: teacherAnnouncementId }, }), 0, ); user = { ...user, role: UserRole.ERZIEHER }; assert.equal( (await markAnnouncementRead(teacherAnnouncementId)).success, true, ); user = { ...user, kitaId: otherKitaId }; assert.ok((await markAnnouncementRead(teacherAnnouncementId)).error); }, ); } finally { await db.kita.deleteMany({ where: { id: { in: [kitaId, otherKitaId] } }, }); await db.$disconnect(); const { prisma } = loadAfterMocks( "../src/lib/prisma", ) as typeof import("../src/lib/prisma"); await prisma.$disconnect(); mock.restoreAll(); } }, );