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>
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
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" } });
|
|
});
|
|
});
|
|
});
|