Merge family absence flows while preserving security hardening
This commit is contained in:
@@ -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()]);
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user