import { PrismaClient } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; /** * Die Unveränderbarkeit abgeschlossener Prüfprotokolle — geprüft dort, wo sie * durchgesetzt wird: in der Datenbank. * * Ein Prüfprotokoll ist der Nachweis gegenüber der Unfallkasse. Wäre es * nachträglich änderbar, wäre es als Nachweis wertlos. Die Sperre sitzt * deshalb in einem Trigger (Migration 20260903000300) und nicht nur im * Anwendungscode — ein vergessener Zustandscheck im Router würde sonst reichen, * um sie auszuhebeln. * * Der Test braucht eine laufende Datenbank. Ist keine erreichbar, überspringt * er sich selbst, damit "npm run test" auch ohne Docker durchläuft. */ 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-pruefprotokoll-wehr"; let erreichbar = false; let organisationId = ""; let geraetId = ""; let vorlageId = ""; let punktId = ""; 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: "Prüfprotokoll-Testwehr", kurzname: "PPT" }, }); organisationId = organisation.id; const standort = await system.standort.upsert({ where: { organisationId_name: { organisationId, name: "Gerätehaus" } }, update: {}, create: { organisationId, name: "Gerätehaus", art: "GERAETEHAUS" }, }); const geraet = await system.geraet.upsert({ where: { organisationId_inventarnummer: { organisationId, inventarnummer: "PPT-0001" } }, update: {}, create: { organisationId, inventarnummer: "PPT-0001", bezeichnung: "Prüfling", standortId: standort.id, }, }); geraetId = geraet.id; const vorlage = await system.wartungsvorlage.upsert({ where: { organisationId_name: { organisationId, name: "Testvorlage" } }, update: {}, create: { organisationId, name: "Testvorlage", intervallMonate: 12 }, }); vorlageId = vorlage.id; const punkt = await system.checklistenpunkt.create({ data: { organisationId, vorlageId, text: "Sichtprüfung", typ: "JA_NEIN" }, }); punktId = punkt.id; }); afterAll(async () => { if (erreichbar) { // Der Schalter ist zweimal nötig: für die Audit-Einträge und für die // abgeschlossenen Prüfungen, die derselbe Aufbewahrungslauf abräumt. 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()]); }); /** Legt eine abgeschlossene Prüfung an — so wie es der Wizard tut. */ async function abgeschlossenePruefung(nummer: string) { const pruefung = await system.pruefung.create({ data: { organisationId, geraetId, vorlageId, durchgefuehrtAm: new Date("2026-06-01T00:00:00.000Z"), prueferName: "Testprüfer", status: "ENTWURF", ergebnisse: { create: [{ organisationId, punktId, jaNein: true, inOrdnung: true }] }, signaturen: { create: [{ organisationId, name: "Testprüfer" }] }, }, }); return system.pruefung.update({ where: { id: pruefung.id }, data: { status: "ABGESCHLOSSEN", ergebnis: "BESTANDEN", protokollNummer: nummer, abgeschlossenAm: new Date(), }, }); } describe.runIf(process.env.DATABASE_URL)("Abgeschlossene Prüfprotokolle", () => { it("lässt Entwürfe uneingeschränkt ändern", async ({ skip }) => { if (!erreichbar) return skip(); const entwurf = await system.pruefung.create({ data: { organisationId, geraetId, vorlageId, durchgefuehrtAm: new Date(), status: "ENTWURF", }, }); const geaendert = await system.pruefung.update({ where: { id: entwurf.id }, data: { bemerkung: "noch in Arbeit" }, }); expect(geaendert.bemerkung).toBe("noch in Arbeit"); await system.pruefung.delete({ where: { id: entwurf.id } }); }); it("weist jede inhaltliche Änderung am abgeschlossenen Protokoll ab", async ({ skip }) => { if (!erreichbar) return skip(); const pruefung = await abgeschlossenePruefung("T-0001"); await expect( system.pruefung.update({ where: { id: pruefung.id }, data: { ergebnis: "NICHT_BESTANDEN" }, }), ).rejects.toThrow(/unveraenderlich/i); await expect( system.pruefung.update({ where: { id: pruefung.id }, data: { bemerkung: "nachträglich ergänzt" }, }), ).rejects.toThrow(/unveraenderlich/i); await expect( system.pruefung.update({ where: { id: pruefung.id }, data: { durchgefuehrtAm: new Date("2020-01-01T00:00:00.000Z") }, }), ).rejects.toThrow(/unveraenderlich/i); }); it("lässt das Protokoll-PDF nachtragen, solange keines hinterlegt ist", async ({ skip }) => { if (!erreichbar) return skip(); const pruefung = await abgeschlossenePruefung("T-0002"); const datei = await system.datei.create({ data: { organisationId, bucket: "test", objektSchluessel: `test/${pruefung.id}.pdf`, dateiname: "protokoll.pdf", mimeTyp: "application/pdf", groesseBytes: BigInt(1024), }, }); // Das ist der Weg des Hintergrundprozesses — er muss offen bleiben. const mitPdf = await system.pruefung.update({ where: { id: pruefung.id }, data: { protokollDateiId: datei.id }, }); expect(mitPdf.protokollDateiId).toBe(datei.id); // Ein zweites Mal aber nicht: sonst ließe sich das Protokoll austauschen. const zweite = await system.datei.create({ data: { organisationId, bucket: "test", objektSchluessel: `test/${pruefung.id}-zwei.pdf`, dateiname: "anderes.pdf", mimeTyp: "application/pdf", groesseBytes: BigInt(2048), }, }); await expect( system.pruefung.update({ where: { id: pruefung.id }, data: { protokollDateiId: zweite.id }, }), ).rejects.toThrow(/unveraenderlich/i); }); it("erlaubt genau einen Zustandswechsel: abgeschlossen wird ersetzt", async ({ skip }) => { if (!erreichbar) return skip(); const pruefung = await abgeschlossenePruefung("T-0003"); const ersetzt = await system.pruefung.update({ where: { id: pruefung.id }, data: { status: "ERSETZT" }, }); expect(ersetzt.status).toBe("ERSETZT"); // Zurück in den Entwurf geht es nicht. await expect( system.pruefung.update({ where: { id: pruefung.id }, data: { status: "ENTWURF" } }), ).rejects.toThrow(/unveraenderlich/i); }); it("sperrt auch den Umweg über die Einzelergebnisse", async ({ skip }) => { if (!erreichbar) return skip(); const pruefung = await abgeschlossenePruefung("T-0004"); const ergebnis = await system.pruefergebnis.findFirstOrThrow({ where: { pruefungId: pruefung.id }, }); await expect( system.pruefergebnis.update({ where: { id: ergebnis.id }, data: { jaNein: false, inOrdnung: false }, }), ).rejects.toThrow(/abgeschlossen/i); await expect( system.pruefergebnis.delete({ where: { id: ergebnis.id } }), ).rejects.toThrow(/abgeschlossen/i); await expect( system.pruefergebnis.create({ data: { organisationId, pruefungId: pruefung.id, punktId, jaNein: false }, }), ).rejects.toThrow(/abgeschlossen/i); }); it("sperrt nachträgliche Unterschriften", async ({ skip }) => { if (!erreichbar) return skip(); const pruefung = await abgeschlossenePruefung("T-0005"); await expect( system.signatur.create({ data: { organisationId, pruefungId: pruefung.id, name: "Nachzügler" }, }), ).rejects.toThrow(/abgeschlossen/i); }); it("verhindert das Löschen abgeschlossener Protokolle", async ({ skip }) => { if (!erreichbar) return skip(); const pruefung = await abgeschlossenePruefung("T-0006"); await expect(system.pruefung.delete({ where: { id: pruefung.id } })).rejects.toThrow( /nicht geloescht/i, ); // Nur der ausdrückliche Aufbewahrungslauf darf es. await system.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; await tx.pruefung.delete({ where: { id: pruefung.id } }); }); expect(await system.pruefung.findUnique({ where: { id: pruefung.id } })).toBeNull(); }); }); describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der Wartungstabellen", () => { it("liefert ohne gesetzten Mandantenkontext keine Zeilen", async ({ skip }) => { if (!erreichbar) return skip(); const gefunden = await app.$transaction(async (tx) => ({ vorlagen: await tx.wartungsvorlage.findMany(), termine: await tx.wartungstermin.findMany(), pruefungen: await tx.pruefung.findMany(), maengel: await tx.mangel.findMany(), rueckrufe: await tx.rueckruf.findMany(), })); expect(gefunden.vorlagen).toHaveLength(0); expect(gefunden.termine).toHaveLength(0); expect(gefunden.pruefungen).toHaveLength(0); expect(gefunden.maengel).toHaveLength(0); expect(gefunden.rueckrufe).toHaveLength(0); }); it("zeigt im Kontext der Wehr nur ihre eigenen Vorlagen", async ({ skip }) => { if (!erreichbar) return skip(); const vorlagen = await app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`; return tx.wartungsvorlage.findMany(); }); expect(vorlagen.length).toBeGreaterThan(0); expect(vorlagen.every((vorlage) => vorlage.organisationId === organisationId)).toBe(true); }); it("lässt keine Vorlage für eine fremde Wehr anlegen", async ({ skip }) => { if (!erreichbar) return skip(); const fremde = await system.organisation.upsert({ where: { slug: "test-pruefprotokoll-fremd" }, update: {}, create: { slug: "test-pruefprotokoll-fremd", name: "Fremde Wehr", kurzname: "FW" }, }); await expect( app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`; return tx.wartungsvorlage.create({ data: { organisationId: fremde.id, name: "Schmuggelvorlage", intervallMonate: 12 }, }); }), ).rejects.toThrow(); await system.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; await tx.organisation.deleteMany({ where: { slug: "test-pruefprotokoll-fremd" } }); }); }); });