Files
kita-planer/tests/parent-flows.integration.test.ts
T

417 lines
14 KiB
TypeScript

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<string, unknown> },
) => 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();
}
},
);