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 = {}) { 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); }); });