Files
FireAdministration/tests/unit/termin-datenbank.test.ts
T
Florian WildeandClaude Opus 5 0b8874da26 Phase 7: Benachrichtigungen und Termine
Terminkalender mit Serienterminen, Selbstanmeldung, Warteliste und
Anwesenheitsliste. Vier Zustellwege hinter Schnittstellen: echtes SMTP,
ntfy (selbst gehostet), Telegram und Web-Push. Kalenderabonnement als
ICS-Feed, digitaler Dienstausweis mit signiertem QR und Pruefseite.

Niemand wird ungefragt benachrichtigt: ein neuer Zustellweg hat eine leere
Ereignisliste, und der Seed legt bewusst keine an. Was das Netz der Wehr
verlaesst, bekommt nur die Kurzfassung ohne Namen und Details.

Kein CalDAV (Lesen genuegt, ICS kann jeder Kalender) und kein WhatsApp
(Verarbeitung ueber Meta, Kosten je Konversation, Telefonbuchabgleich) --
beides im README begruendet.

56 neue Unit-Tests, 17 neue E2E-Tests, Version 0.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 08:39:38 +02:00

317 lines
10 KiB
TypeScript

import { PrismaClient } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
/**
* Was die Datenbank bei Terminen und Benachrichtigungen durchsetzt.
*
* Vier Dinge, die nicht im Anwendungscode stehen dürfen, weil sie auch dann
* gelten müssen, wenn jemand am Router vorbei schreibt:
*
* 1. Unmögliche Termine werden abgewiesen: Ende vor Beginn, Anmeldeschluss
* nach dem Termin, ein Kontingent von null Plätzen.
* 2. Eine Person meldet sich zu einem Termin genau einmal zurück.
* 3. Verschickte Benachrichtigungen lassen sich nicht löschen — wer wann
* worüber informiert wurde, ist im Zweifel die entscheidende Frage.
* 4. Mandantentrennung der sechs neuen Tabellen.
*
* Ohne erreichbare Datenbank überspringen sich die Tests selbst.
*/
const APP_URL = process.env.DATABASE_URL ?? "";
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL;
const app = new PrismaClient({ datasources: { db: { url: APP_URL } } });
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
const SLUG = "test-termin-wehr";
let erreichbar = false;
let organisationId = "";
let personA = "";
let personB = "";
const BEGINN = new Date("2026-10-13T17:00:00.000Z");
beforeAll(async () => {
try {
await system.$queryRaw`SELECT 1`;
erreichbar = true;
} catch {
return;
}
const organisation = await system.organisation.upsert({
where: { slug: SLUG },
update: {},
create: { slug: SLUG, name: "Termin-Testwehr", kurzname: "TTW" },
});
organisationId = organisation.id;
const anlegen = async (nummer: string, vorname: string) => {
const person = await system.person.upsert({
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: nummer } },
update: {},
create: { organisationId, mitgliedsnummer: nummer, vorname, nachname: "Termin" },
});
return person.id;
};
personA = await anlegen("T001", "Anton");
personB = await anlegen("T002", "Berta");
});
afterAll(async () => {
if (erreichbar) {
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.organisation.deleteMany({ where: { slug: SLUG } });
});
}
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
});
/** Ein Termin mit sinnvollen Vorgabewerten. */
async function terminAnlegen(felder: Record<string, unknown> = {}) {
return system.termin.create({
data: { organisationId, titel: "Testtermin", beginn: BEGINN, ...felder },
});
}
describe.runIf(process.env.DATABASE_URL)("Die Datenbank weist unmögliche Termine ab", () => {
it("nimmt einen gewöhnlichen Termin an", async ({ skip }) => {
if (!erreichbar) return skip();
const termin = await terminAnlegen({
titel: "Übungsabend",
ende: new Date("2026-10-13T19:00:00.000Z"),
});
expect(termin.titel).toBe("Übungsabend");
});
it("weist ein Ende vor dem Beginn ab", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
terminAnlegen({ ende: new Date("2026-10-13T15:00:00.000Z") }),
).rejects.toThrow(/termin_ende_nach_beginn/);
});
it("weist einen Anmeldeschluss nach dem Termin ab", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
terminAnlegen({ anmeldeschluss: new Date("2026-10-20T00:00:00.000Z") }),
).rejects.toThrow(/termin_anmeldeschluss_vorher/);
});
it("weist ein Kontingent von null Plätzen ab", async ({ skip }) => {
if (!erreichbar) return skip();
// Null Plätze hieße: alle auf die Warteliste, niemand kommt je nach.
// Wer den Termin niemandem geben will, sagt ihn ab.
await expect(terminAnlegen({ teilnehmerLimit: 0 })).rejects.toThrow(
/termin_limit_sinnvoll/,
);
});
it("lässt ein Ende genau zum Beginn zu", async ({ skip }) => {
if (!erreichbar) return skip();
// Ein Termin ohne Dauer ist nicht sinnlos: so trägt man einen Stichtag ein.
const termin = await terminAnlegen({ ende: BEGINN });
expect(termin.ende).toEqual(BEGINN);
});
});
describe.runIf(process.env.DATABASE_URL)("Rückmeldungen", () => {
it("lässt eine Person nur eine Rückmeldung je Termin abgeben", async ({ skip }) => {
if (!erreichbar) return skip();
const termin = await terminAnlegen({ titel: "Einmalig", anmeldungNoetig: true });
await system.terminanmeldung.create({
data: { organisationId, terminId: termin.id, personId: personA, stand: "ZUGESAGT" },
});
await expect(
system.terminanmeldung.create({
data: { organisationId, terminId: termin.id, personId: personA, stand: "ABGESAGT" },
}),
).rejects.toThrow();
});
it("erlaubt verschiedenen Personen dieselbe Rückmeldung", async ({ skip }) => {
if (!erreichbar) return skip();
const termin = await terminAnlegen({ titel: "Zu zweit", anmeldungNoetig: true });
await system.terminanmeldung.create({
data: { organisationId, terminId: termin.id, personId: personA, stand: "ZUGESAGT" },
});
const zweite = await system.terminanmeldung.create({
data: { organisationId, terminId: termin.id, personId: personB, stand: "ZUGESAGT" },
});
expect(zweite.stand).toBe("ZUGESAGT");
});
it("verschwindet mit dem Termin", async ({ skip }) => {
if (!erreichbar) return skip();
const termin = await terminAnlegen({ titel: "Wird entfernt" });
await system.terminanmeldung.create({
data: { organisationId, terminId: termin.id, personId: personA, stand: "ZUGESAGT" },
});
await system.termin.delete({ where: { id: termin.id } });
const uebrig = await system.terminanmeldung.count({ where: { terminId: termin.id } });
expect(uebrig).toBe(0);
});
});
describe.runIf(process.env.DATABASE_URL)("Benachrichtigungen sind nachvollziehbar", () => {
it("nimmt eine Benachrichtigung an", async ({ skip }) => {
if (!erreichbar) return skip();
const eintrag = await system.benachrichtigung.create({
data: {
organisationId,
personId: personA,
ereignis: "TERMIN_NEU",
kanal: "MAIL",
text: "Ein neuer Termin.",
ziel: "anton@example.org",
},
});
expect(eintrag.stand).toBe("WARTET");
});
/**
* DELETE ist der Anwendungsrolle entzogen. Wer wann worüber informiert
* wurde, soll sich nicht über die Oberfläche wegräumen lassen — die
* Aufbewahrungsfrist räumt auf, und die läuft über den Systemzugang.
*/
it("lässt sich über die Anwendungsrolle nicht löschen", async ({ skip }) => {
if (!erreichbar) return skip();
const eintrag = await system.benachrichtigung.create({
data: {
organisationId,
personId: personA,
ereignis: "MANGEL_GEMELDET",
kanal: "MAIL",
text: "Ein Mangel.",
ziel: "anton@example.org",
},
});
await expect(
app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
await tx.benachrichtigung.delete({ where: { id: eintrag.id } });
}),
).rejects.toThrow();
});
it("weist eine negative Versuchszahl ab", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
system.benachrichtigung.create({
data: {
organisationId,
personId: personA,
ereignis: "TERMIN_NEU",
kanal: "MAIL",
text: "Unsinn",
ziel: "x",
versuche: -1,
},
}),
).rejects.toThrow(/benachrichtigung_versuche_plausibel/);
});
});
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der neuen Tabellen", () => {
it("liefert ohne gesetzten Mandanten nichts", async ({ skip }) => {
if (!erreichbar) return skip();
await terminAnlegen({ titel: "Unsichtbar ohne Kontext" });
// Ohne app.current_organisation greift die Row Level Security: null Zeilen.
const gefunden = await app.termin.findMany({ where: { organisationId } });
expect(gefunden).toHaveLength(0);
});
it("liefert mit gesetztem Mandanten die eigenen Termine", async ({ skip }) => {
if (!erreichbar) return skip();
const gefunden = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.termin.findMany({ where: { organisationId } });
});
expect(gefunden.length).toBeGreaterThan(0);
});
it("verbirgt die Termine einer fremden Wehr", async ({ skip }) => {
if (!erreichbar) return skip();
const fremde = await system.organisation.upsert({
where: { slug: "test-termin-fremde" },
update: {},
create: { slug: "test-termin-fremde", name: "Fremde Wehr", kurzname: "FW" },
});
await system.termin.create({
data: { organisationId: fremde.id, titel: "Nicht für uns", beginn: BEGINN },
});
const gefunden = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.termin.findMany({ where: { titel: "Nicht für uns" } });
});
expect(gefunden).toHaveLength(0);
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.organisation.deleteMany({ where: { slug: "test-termin-fremde" } });
});
});
it("schützt auch Zustellkanäle und Kalenderzugänge", async ({ skip }) => {
if (!erreichbar) return skip();
await system.zustellkanal.create({
data: {
organisationId,
personId: personA,
kanal: "NTFY",
ziel: "florian-geheim",
ereignisse: ["TERMIN_NEU"],
},
});
await system.kalenderzugang.create({
data: {
organisationId,
personId: personA,
bezeichnung: "Testhandy",
tokenHash: "a".repeat(64),
},
});
const [kanaele, zugaenge] = await Promise.all([
app.zustellkanal.findMany({ where: { organisationId } }),
app.kalenderzugang.findMany({ where: { organisationId } }),
]);
expect(kanaele).toHaveLength(0);
expect(zugaenge).toHaveLength(0);
});
});