import { PrismaClient } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; /** * Was die Datenbank beim Atemschutz durchsetzt. * * Dieses Modul ist das sicherheitsrelevanteste im Projekt, und im Schadensfall * ist das Protokoll das Papier, nach dem gefragt wird. Deshalb liegen die * Regeln nicht (nur) im Anwendungscode: * * 1. Druckmessungen und Ereignisse lassen sich nicht ändern und nicht * löschen — weder über die Anwendung noch über den Systemzugang. * 2. Unmögliche Werte werden abgewiesen: negativer Druck, ein Rückzug vor * dem Einstieg, ein Ende vor dem Beginn. * 3. Genau ein Truppführer je Trupp. * 4. Mandantentrennung der sieben 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-atemschutz-wehr"; let erreichbar = false; let organisationId = ""; let personA = ""; let personB = ""; let ueberwachungId = ""; 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: "Atemschutz-Testwehr", kurzname: "ATW" }, }); organisationId = organisation.id; const anlegen = async (nummer: string, vorname: string, nachname: string) => { const person = await system.person.upsert({ where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: nummer } }, update: {}, create: { organisationId, mitgliedsnummer: nummer, vorname, nachname }, }); return person.id; }; personA = await anlegen("A001", "Anton", "Atemschutz"); personB = await anlegen("A002", "Berta", "Atemschutz"); const ueberwachung = await system.atemschutzeinsatz.create({ data: { organisationId, bezeichnung: "Testüberwachung", ueberwacherId: personA, beginnUm: new Date("2026-06-01T10:00:00.000Z"), }, }); ueberwachungId = ueberwachung.id; }); afterAll(async () => { if (erreichbar) { await system.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; // Protokollzeilen lassen sich nicht loeschen — die Trigger sitzen auf // den Tabellen. Fuer das Aufraeumen der Testdaten werden sie kurz // abgeschaltet; im Betrieb passiert das nie. // Trigger fuer das Aufraeumen stilllegen — sitzungslokal. // // NICHT ueber `ALTER TABLE ... DISABLE TRIGGER`: das ist eine // Schemaänderung und gilt fuer alle. Vitest faehrt die Testdateien // parallel, und waehrend hier abgeraeumt wurde, pruefte anderswo // eine Datei, dass genau dieser Trigger greift — und sah keinen. // `session_replication_role` wirkt nur in dieser Transaktion. await tx.$executeRawUnsafe("SET LOCAL session_replication_role = 'replica'"); await tx.organisation.deleteMany({ where: { slug: SLUG } }); }); } await Promise.allSettled([app.$disconnect(), system.$disconnect()]); }); /** Legt einen Trupp mit zwei Personen an. */ async function truppAnlegen(funkrufname: string, felder: Record = {}) { return system.atemschutztrupp.create({ data: { organisationId, atemschutzeinsatzId: ueberwachungId, funkrufname, angelegtUm: new Date("2026-06-01T10:05:00.000Z"), mitglieder: { create: [ { organisationId, personId: personA, truppfuehrer: true }, { organisationId, personId: personB, truppfuehrer: false }, ], }, ...felder, }, }); } describe.runIf(process.env.DATABASE_URL)("Das Protokoll ist unveränderlich", () => { it("nimmt eine Druckmessung an", async ({ skip }) => { if (!erreichbar) return skip(); const trupp = await truppAnlegen("Messtrupp"); const messung = await system.druckmessung.create({ data: { organisationId, truppId: trupp.id, personId: personA, druck: 300, art: "EINGANG", }, }); expect(messung.druck).toBe(300); }); it("weist jede Änderung einer Druckmessung ab", async ({ skip }) => { if (!erreichbar) return skip(); const trupp = await truppAnlegen("Änderungstrupp"); const messung = await system.druckmessung.create({ data: { organisationId, truppId: trupp.id, personId: personA, druck: 280, art: "ZWISCHEN" }, }); // Ein Tippfehler wird durch eine neue Messung richtiggestellt — nicht // durch Überschreiben. Sonst ließe sich jede Aufzeichnung glätten. await expect( system.druckmessung.update({ where: { id: messung.id }, data: { druck: 180 } }), ).rejects.toThrow(/unveraenderlich/i); }); it("weist das Löschen einer Druckmessung ab", async ({ skip }) => { if (!erreichbar) return skip(); const trupp = await truppAnlegen("Löschtrupp"); const messung = await system.druckmessung.create({ data: { organisationId, truppId: trupp.id, personId: personA, druck: 260, art: "ZWISCHEN" }, }); await expect( system.druckmessung.delete({ where: { id: messung.id } }), ).rejects.toThrow(/unveraenderlich/i); }); it("weist jede Änderung eines Ereignisses ab", async ({ skip }) => { if (!erreichbar) return skip(); const ereignis = await system.atemschutzEreignis.create({ data: { organisationId, atemschutzeinsatzId: ueberwachungId, art: "MAYDAY", text: "MAYDAY — Angriffstrupp", }, }); await expect( system.atemschutzEreignis.update({ where: { id: ereignis.id }, data: { text: "war doch nichts" }, }), ).rejects.toThrow(/unveraenderlich/i); }); }); describe.runIf(process.env.DATABASE_URL)("Werte, die es nicht geben darf", () => { it("weist einen negativen Druck ab", async ({ skip }) => { if (!erreichbar) return skip(); const trupp = await truppAnlegen("Minustrupp"); await expect( system.druckmessung.create({ data: { organisationId, truppId: trupp.id, personId: personA, druck: -5, art: "ZWISCHEN" }, }), ).rejects.toThrow(/druckmessung_druck_moeglich/i); }); it("weist einen Druck jenseits jeder Flasche ab", async ({ skip }) => { if (!erreichbar) return skip(); const trupp = await truppAnlegen("Zahlendrehertrupp"); // 3000 statt 300 — der klassische Tippfehler unter Zeitdruck. await expect( system.druckmessung.create({ data: { organisationId, truppId: trupp.id, personId: personA, druck: 3000, art: "EINGANG" }, }), ).rejects.toThrow(/druckmessung_druck_moeglich/i); }); it("weist einen Rückzug vor dem Einstieg ab", async ({ skip }) => { if (!erreichbar) return skip(); await expect( truppAnlegen("Verdrehttrupp", { einstiegUm: new Date("2026-06-01T10:30:00.000Z"), rueckzugUm: new Date("2026-06-01T10:20:00.000Z"), }), ).rejects.toThrow(/trupp_rueckzug_nach_einstieg/i); }); it("weist einen Rückzug ohne Einstieg ab", async ({ skip }) => { if (!erreichbar) return skip(); // Ein Trupp, der zurückgeht, ohne je drin gewesen zu sein, ist kein // Trupp, sondern ein Erfassungsfehler. await expect( truppAnlegen("Geistertrupp", { rueckzugUm: new Date("2026-06-01T10:20:00.000Z") }), ).rejects.toThrow(/trupp_rueckzug_nach_einstieg/i); }); it("weist eine Aufhebung ohne Notfall ab", async ({ skip }) => { if (!erreichbar) return skip(); await expect( truppAnlegen("Scheintrupp", { maydayAufgehobenUm: new Date("2026-06-01T10:40:00.000Z"), }), ).rejects.toThrow(/trupp_mayday_aufhebung_danach/i); }); it("weist ein Ende vor dem Beginn ab", async ({ skip }) => { if (!erreichbar) return skip(); await expect( system.atemschutzeinsatz.create({ data: { organisationId, bezeichnung: "Rückwärtsüberwachung", ueberwacherId: personA, beginnUm: new Date("2026-06-01T12:00:00.000Z"), endeUm: new Date("2026-06-01T11:00:00.000Z"), }, }), ).rejects.toThrow(/atemschutz_ende_nach_beginn/i); }); it("weist ein unsinniges Abfrageintervall ab", async ({ skip }) => { if (!erreichbar) return skip(); await expect( system.atemschutzeinsatz.create({ data: { organisationId, bezeichnung: "Sekundentakt", ueberwacherId: personA, beginnUm: new Date("2026-06-01T12:00:00.000Z"), abfrageIntervall: 5, }, }), ).rejects.toThrow(/atemschutz_intervall_sinnvoll/i); }); it("weist eine G26 ab, die vor der Untersuchung endet", async ({ skip }) => { if (!erreichbar) return skip(); await expect( system.g26Untersuchung.create({ data: { organisationId, personId: personA, untersuchtAm: new Date("2026-06-01T00:00:00.000Z"), gueltigBis: new Date("2025-06-01T00:00:00.000Z"), }, }), ).rejects.toThrow(/g26_gueltig_nach_untersuchung/i); }); }); describe.runIf(process.env.DATABASE_URL)("Genau ein Truppführer", () => { it("lässt einen zweiten Truppführer nicht zu", async ({ skip }) => { if (!erreichbar) return skip(); const trupp = await truppAnlegen("Doppelfuehrertrupp"); // Im Protokoll muss stehen, wer verantwortlich war — genau einer. Der // Teilindex truppmitglied_ein_fuehrer setzt das durch; Prisma meldet ihn // als verletzte Eindeutigkeit auf truppId. await expect( system.truppmitglied.update({ where: { truppId_personId: { truppId: trupp.id, personId: personB } }, data: { truppfuehrer: true }, }), ).rejects.toThrow(/unique constraint failed.*truppId/i); }); it("lässt dieselbe Person nicht zweimal im Trupp zu", async ({ skip }) => { if (!erreichbar) return skip(); const trupp = await truppAnlegen("Doppeltrupp"); await expect( system.truppmitglied.create({ data: { organisationId, truppId: trupp.id, personId: personA }, }), ).rejects.toThrow(); }); }); describe.runIf(process.env.DATABASE_URL)("Mandantentrennung des Atemschutzes", () => { it("zeigt ohne gesetzte Wehr nichts", async ({ skip }) => { if (!erreichbar) return skip(); const gefunden = await app.$transaction(async (tx) => ({ ueberwachungen: await tx.atemschutzeinsatz.findMany(), trupps: await tx.atemschutztrupp.findMany(), mitglieder: await tx.truppmitglied.findMany(), messungen: await tx.druckmessung.findMany(), ereignisse: await tx.atemschutzEreignis.findMany(), untersuchungen: await tx.g26Untersuchung.findMany(), uebungen: await tx.belastungsuebung.findMany(), })); for (const [tabelle, zeilen] of Object.entries(gefunden)) { expect(zeilen, `${tabelle} darf ohne Mandantenkontext nichts zeigen`).toHaveLength(0); } }); it("zeigt im Kontext der Wehr nur deren eigene Überwachungen", async ({ skip }) => { if (!erreichbar) return skip(); const ueberwachungen = await app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`; return tx.atemschutzeinsatz.findMany(); }); expect(ueberwachungen.length).toBeGreaterThan(0); expect(ueberwachungen.every((eintrag) => eintrag.organisationId === organisationId)).toBe(true); }); it("lässt den Anwendungszugang eine Messung anlegen, aber nicht ändern", async ({ skip }) => { if (!erreichbar) return skip(); const trupp = await truppAnlegen("Rechtetrupp"); const messungId = await app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`; const messung = await tx.druckmessung.create({ data: { organisationId, truppId: trupp.id, personId: personA, druck: 210, art: "ZWISCHEN" }, }); return messung.id; }); expect(messungId).toBeTruthy(); await expect( app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`; return tx.$executeRaw`UPDATE "druckmessung" SET "druck" = 999 WHERE "id" = ${messungId}::uuid`; }), ).rejects.toThrow(); }); });