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:
co-authored by
Claude Opus 5
parent
7f866ac35c
commit
e503aafc18
@@ -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" } });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user