Merge main with notdienst planning and notification improvements
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
absenceDate,
|
||||
absenceToday,
|
||||
canEditOwnAbsence,
|
||||
canManageKitaAbsences,
|
||||
} from "../src/lib/absence-policy";
|
||||
import { familyInvitationView } from "../src/lib/family-invitation-view";
|
||||
|
||||
test("Abwesenheitsdatum akzeptiert echte Kalendertage und lehnt Überläufe ab", () => {
|
||||
assert.equal(
|
||||
absenceDate("2028-02-29").toISOString(),
|
||||
"2028-02-29T00:00:00.000Z",
|
||||
);
|
||||
for (const value of [
|
||||
"2026-02-29",
|
||||
"2026-04-31",
|
||||
"2026-13-01",
|
||||
"2026-1-09",
|
||||
"2026-10-09T12:00:00",
|
||||
"invalid",
|
||||
])
|
||||
assert.throws(() => absenceDate(value));
|
||||
});
|
||||
|
||||
test("Die Datumsgrenze folgt Berlin auch über Mitternacht und die Zeitumstellung", () => {
|
||||
assert.equal(absenceToday(new Date("2026-10-09T21:59:59Z")), "2026-10-09");
|
||||
assert.equal(absenceToday(new Date("2026-10-09T22:00:00Z")), "2026-10-10");
|
||||
assert.equal(absenceToday(new Date("2026-10-25T22:59:59Z")), "2026-10-25");
|
||||
assert.equal(absenceToday(new Date("2026-10-25T23:00:00Z")), "2026-10-26");
|
||||
assert.equal(canEditOwnAbsence("2026-10-09", "2026-10-09"), false);
|
||||
assert.equal(canEditOwnAbsence("2026-10-10", "2026-10-09"), true);
|
||||
assert.equal(canManageKitaAbsences("ELTERN"), false);
|
||||
for (const role of ["ADMIN", "KOORDINATOR", "ERZIEHER"])
|
||||
assert.equal(canManageKitaAbsences(role), true);
|
||||
});
|
||||
|
||||
test("Einladungsanzeige unterscheidet Aktivierung, Fehler, Ablauf und laufenden Versand", () => {
|
||||
const now = new Date("2026-10-09T12:00:00Z");
|
||||
const base = {
|
||||
expiresAt: new Date("2026-10-16T12:00:00Z"),
|
||||
sentAt: null,
|
||||
failed: false,
|
||||
leaseUntil: null,
|
||||
status: "PENDING",
|
||||
};
|
||||
assert.equal(familyInvitationView(null, null, now).status, "open");
|
||||
assert.equal(
|
||||
familyInvitationView(null, { ...base, failed: true }, now).status,
|
||||
"failed",
|
||||
);
|
||||
assert.equal(
|
||||
familyInvitationView(null, { ...base, expiresAt: now }, now).status,
|
||||
"expired",
|
||||
);
|
||||
assert.equal(
|
||||
familyInvitationView(null, { ...base, sentAt: now }, now).status,
|
||||
"sent",
|
||||
);
|
||||
assert.equal(
|
||||
familyInvitationView(
|
||||
null,
|
||||
{ ...base, leaseUntil: new Date(now.getTime() + 1000) },
|
||||
now,
|
||||
).status,
|
||||
"sending",
|
||||
);
|
||||
assert.equal(
|
||||
familyInvitationView(now, { ...base, failed: true }, now).status,
|
||||
"active",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,786 @@
|
||||
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()]);
|
||||
}
|
||||
},
|
||||
);
|
||||
+30
-1
@@ -163,6 +163,7 @@ test('database blocks cross-tenant child, user, RSVP and announcement links', as
|
||||
await assert.rejects(prisma.child.create({ data: { firstName: 'Wrong', lastName: 'Tenant', kitaId: kitaA.id, familyId: familyB.id } }), error => error.code === 'P2003');
|
||||
await assert.rejects(prisma.user.update({ where: { id: parentA.id }, data: { familyId: familyB.id } }), error => error.code === 'P2003');
|
||||
await assert.rejects(prisma.announcement.create({ data: { kitaId: kitaA.id, authorId: parentB.id, title: 'Wrong', content: 'Wrong' } }), error => error.code === 'P2003');
|
||||
await assert.rejects(prisma.invitation.create({ data: { kitaId: kitaA.id, userId: parentB.id, email: 'cross-' + prefix + '@example.invalid', token: randomUUID(), expiresAt: new Date(Date.now() + 60000), invitedById: adminA.id } }), error => error.code === 'P2003');
|
||||
const event = await prisma.termin.create({ data: { kitaId: kitaA.id, title: 'Event', type: 'SONSTIGES', status: 'CONFIRMED', startDate: new Date(), endDate: new Date() } });
|
||||
await assert.rejects(prisma.eventParticipation.create({ data: { kitaId: kitaA.id, eventId: event.id, childId: childB.id, status: 'ATTENDING' } }), error => error.code === 'P2003');
|
||||
});
|
||||
@@ -304,6 +305,7 @@ test('changing a parent email revokes sessions and requires fresh mailbox activa
|
||||
assert.equal(await authConfig.callbacks.jwt({ token: oldToken }),null);
|
||||
assert.equal(await authConfig.providers[0].authorize({ email: newEmail, password: 'Safe-test-password' }),null);
|
||||
const initialInvite = emails.at(-1).react.props.inviteLink.split('/').at(-1);
|
||||
await prisma.invitation.update({ where: { userId: parentA.id }, data: { sentAt: new Date(Date.now() - 61_000) } });
|
||||
assert.equal((await actions.resendParentInvite(parentA.id)).success,true);
|
||||
const renewedInvite = emails.at(-1).react.props.inviteLink.split('/').at(-1);
|
||||
assert.notEqual(initialInvite,renewedInvite);
|
||||
@@ -313,6 +315,33 @@ test('changing a parent email revokes sessions and requires fresh mailbox activa
|
||||
assert.equal((await accept({ attempt: 0 }, form(renewedInvite))).errors,undefined);
|
||||
assert.ok((await prisma.user.findUnique({ where: { id: parentA.id } })).emailVerifiedAt);
|
||||
});
|
||||
test('family admins keep their role when a changed mailbox is invited and activated', async () => {
|
||||
const family = await prisma.family.create({ data: { kitaId: kitaA.id, name: 'Admin family' } });
|
||||
const child = await prisma.child.create({ data: { kitaId: kitaA.id, familyId: family.id, firstName: 'Admin', lastName: 'Child' } });
|
||||
const user = await prisma.user.create({ data: { email: `family-admin.${prefix}@example.invalid`, firstName: 'Family', lastName: 'Admin',
|
||||
role: 'ADMIN', kitaId: kitaA.id, familyId: family.id, passwordHash: passHash,
|
||||
emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
|
||||
try {
|
||||
const load = loader({ '@/auth': { auth: async () => session(adminA), signIn: async () => {} }, '@/lib/mail': emailMock });
|
||||
const actions = load('src/app/dashboard/families/actions.ts');
|
||||
const updated = await actions.updateFamilyAction({ familyId: family.id, familyName: 'Family A',
|
||||
parents: [{ id: user.id, firstName: 'Family', lastName: 'Admin', email: `changed-family-admin.${prefix}@example.invalid` }],
|
||||
children: [{ id: child.id, firstName: 'A', lastName: 'Test' }] });
|
||||
assert.equal(updated.success,true,updated.error); assert.equal(updated.warning,undefined);
|
||||
assert.equal((await prisma.user.findUnique({ where: { id: user.id } })).role,'ADMIN');
|
||||
await prisma.invitation.update({ where: { userId: user.id }, data: { sentAt: new Date(Date.now() - 61_000) } });
|
||||
assert.equal((await actions.resendParentInvite(user.id)).success,true);
|
||||
const token = emails.at(-1).react.props.inviteLink.split('/').at(-1);
|
||||
const accepted = await load('src/app/invite/[token]/actions.ts').acceptInviteAction({ attempt: 0 }, fields({ token,
|
||||
password: 'Family-admin-new-password', confirmPassword: 'Family-admin-new-password', acceptPrivacyPolicy: 'on' }));
|
||||
assert.equal(accepted.errors,undefined);
|
||||
const activated = await prisma.user.findUnique({ where: { id: user.id } });
|
||||
assert.equal(activated.role,'ADMIN'); assert.ok(activated.emailVerifiedAt);
|
||||
} finally {
|
||||
await prisma.verificationToken.deleteMany({ where: { identifier: user.id } });
|
||||
await prisma.family.delete({ where: { id: family.id } });
|
||||
}
|
||||
});
|
||||
test('resending an invite cannot issue an active link to an obsolete mailbox', async () => {
|
||||
const user = await prisma.user.create({ data: { email: `resend-race.${prefix}@example.invalid`, firstName: 'Race', lastName: 'Test',
|
||||
role: 'ELTERN', kitaId: kitaA.id, familyId: familyA.id, passwordHash: '' } });
|
||||
@@ -355,7 +384,7 @@ test('upload quota holds under concurrent writes and accepted MIME comes from fi
|
||||
|
||||
test('all supplemental database tenant constraints are present', async () => {
|
||||
const [row] = await prisma.$queryRaw`SELECT count(*)::int AS count FROM pg_constraint WHERE conname LIKE '%_tenant_fkey'`;
|
||||
assert.equal(row.count,44);
|
||||
assert.equal(row.count,45);
|
||||
});
|
||||
test('role guard authenticates once and keeps role validation on that session', async () => {
|
||||
let calls = 0;
|
||||
|
||||
Reference in New Issue
Block a user