Merge main with notdienst planning and notification improvements

This commit is contained in:
t.indorf
2026-10-12 15:37:10 +02:00
30 changed files with 2848 additions and 644 deletions
+73
View File
@@ -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
View File
@@ -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;