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

787 lines
26 KiB
TypeScript

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 { hashToken } from "../src/lib/tokens";
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<string>();
let throwTransport = false;
const refreshed: string[] = [];
const moduleMock = mock as typeof mock & {
module: (
name: string,
options: { namedExports: Record<string, unknown> },
) => 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<ReturnType<typeof db.user.findMany>> = [];
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: hashToken(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: hashToken(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: hashToken(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: hashToken(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: hashToken(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: hashToken(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()]);
}
},
);