Phase 2: Wartungs- und Pruefmanagement

Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf,
Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche
Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung.

Elf neue Tabellen, alle mit organisationId und Row Level Security. Die
Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger
durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung
mit Verweis auf die alte, beide bleiben lesbar.

79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 12:07:11 +02:00
co-authored by Claude Opus 5
parent 7f866ac35c
commit e503aafc18
97 changed files with 13367 additions and 133 deletions
@@ -0,0 +1,338 @@
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" } });
});
});
});