import { beforeEach, describe, expect, it } from "vitest"; import { LaufStatus, SchrittZustand, VersionStatus } from "@/generated/prisma/enums"; import { LaufFehler, laufAbschliessen, laufAnlegen, laufFreigeben, laufZurueckweisen, } from "@/server/services/laeufe"; import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Vier-Augen-Freigabe (#040). * * **Eine Zusage trägt diesen Meilenstein:** wer an einem Lauf gearbeitet hat, * gibt ihn nicht frei. Alles andere — das Recht, die Pflicht an der Version, die * Zurückweisung — hängt daran. * * Geprüft wird sie von drei Seiten, weil sie auf drei Wegen umgangen werden * könnte: über das Anlegen, über ein Schrittergebnis und über den Abschluss. Und * ein CHECK in der Datenbank hält die wichtigste Hälfte selbst dann, wenn jemand * an der Dienstschicht vorbei schreibt. */ async function aufbau(freigabePflicht: boolean) { const kunde = await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global" } }); const techniker = await testPrisma.benutzer.create({ data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" }, }); const redakteur = await testPrisma.benutzer.create({ data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" }, }); const admin = await testPrisma.benutzer.create({ data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" }, }); const vorlage = await testPrisma.vorlage.create({ data: { name: "Restore-Test" } }); const version = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF, freigabePflicht }, }); const abschnitt = await testPrisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: "Prüfung", position: 0 }, }); const schritt = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Sicherung zurückspielen", pflicht: true, position: 0 }, }); await testPrisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, }); return { kunde, versionId: version.id, schrittId: schritt.id, techniker: { id: techniker.id, rolle: techniker.rolle }, redakteur: { id: redakteur.id, rolle: redakteur.rolle }, admin: { id: admin.id, rolle: admin.rolle }, }; } /** Ein Lauf, den der Techniker angelegt und abgearbeitet hat. */ async function abgearbeitet(freigabePflicht: boolean) { const stand = await aufbau(freigabePflicht); const lauf = await laufAnlegen({ vorlagenVersionId: stand.versionId }, stand.techniker); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: stand.schrittId, zustand: SchrittZustand.ERLEDIGT }, stand.techniker, ); return { ...stand, laufId: lauf.id }; } describe.runIf(datenbankVerfuegbar)("Ein Lauf mit Freigabepflicht", () => { beforeEach(async () => { await datenbankLeeren(); }); it("lässt sich nicht abschließen, solange keine Freigabe steht", async () => { const { laufId, techniker } = await abgearbeitet(true); await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler); await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(/Freigabe durch einen Zweiten/u); }); it("lässt sich nach der Freigabe abschließen", async () => { const { laufId, techniker, redakteur } = await abgearbeitet(true); await laufFreigeben(laufId, redakteur); const fertig = await laufAbschliessen(laufId, techniker); expect(fertig.status).toBe(LaufStatus.ABGESCHLOSSEN); }); it("hält die Freigabe mit Freigeber und Zeitpunkt fest", async () => { const { laufId, redakteur } = await abgearbeitet(true); const nachher = await laufFreigeben(laufId, redakteur, "Sichtprüfung am 24.10. durch Red"); expect(nachher.freigegebenVonId).toBe(redakteur.id); expect(nachher.freigegebenAm).toBeInstanceOf(Date); expect(nachher.freigabeNotiz).toBe("Sichtprüfung am 24.10. durch Red"); const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { laufId, aktion: "Lauf freigegeben" }, }); expect(eintrag.benutzerId).toBe(redakteur.id); }); }); describe.runIf(datenbankVerfuegbar)("Wer gearbeitet hat, gibt nicht frei", () => { beforeEach(async () => { await datenbankLeeren(); }); it("weist den Techniker ab, der den Lauf angelegt und abgearbeitet hat", async () => { // Er hätte das Recht ohnehin nicht — der nächste Test zeigt den Fall mit Recht. const { laufId, techniker } = await abgearbeitet(true); await expect(laufFreigeben(laufId, techniker)).rejects.toThrow(/Recht "freigeben"/u); }); it("weist auch einen ADMIN ab, der den Lauf angelegt hat", async () => { /* * **Der wichtigste Fall.** Das Recht allein genügt nicht; sonst wäre die * Vier-Augen-Regel für jeden Administrator eine Formsache. Ein Einzelkämpfer * kommt damit nicht weiter — das ist eine bewusste Härte, und die Anleitung * sagt es. */ const { versionId, admin } = await aufbau(true); const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin); await expect(laufFreigeben(lauf.id, admin)).rejects.toThrow(/an diesem Lauf gearbeitet/u); }); it("weist ab, wer nur ein Schrittergebnis gesetzt hat", async () => { /* * Angelegt hat ihn der Techniker, abgearbeitet der Redakteur. Der Redakteur * hat das Recht — aber er hat gearbeitet, also gibt er nicht frei. */ const { versionId, schrittId, techniker, redakteur } = await aufbau(true); const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT }, redakteur, ); await expect(laufFreigeben(lauf.id, redakteur)).rejects.toThrow(/an diesem Lauf gearbeitet/u); }); it("lässt den frei, der nur zugewiesen war", async () => { /* * Die Gegenrichtung, und sie ist Absicht: eine **Zuweisung** ist noch keine * Arbeit. Wer zugewiesen wurde und nichts getan hat, soll freigeben können, * wenn jemand anderes abgearbeitet hat — sonst wäre die Regel strenger als * gemeint und blockierte kleine Teams unnötig. */ const { versionId, schrittId, techniker, redakteur } = await aufbau(true); const lauf = await laufAnlegen({ vorlagenVersionId: versionId, zugewiesenAnId: redakteur.id }, techniker); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT }, techniker, ); await expect(laufFreigeben(lauf.id, redakteur)).resolves.toBeDefined(); }); it("weist ab, wer ihn abgeschlossen hat", async () => { // Ohne Freigabepflicht lässt sich abschließen; danach ist der Abschließende // Bearbeiter und gibt nicht frei. const { versionId, schrittId, techniker, redakteur } = await aufbau(false); const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT }, techniker, ); await laufAbschliessen(lauf.id, redakteur); await expect(laufFreigeben(lauf.id, redakteur)).rejects.toThrow(/an diesem Lauf gearbeitet/u); }); it("wird auch von der Datenbank abgewiesen, nicht nur vom Dienst", async () => { /* * An der Dienstschicht vorbei: der CHECK `Lauf_freigabe_nicht_selbst` hält * die wichtigste Hälfte der Regel — der Ersteller gibt nicht frei (CLAUDE.md, * „Regeln stehen in der Datenbank“). */ const { versionId, admin } = await aufbau(true); const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin); await expect( testPrisma.lauf.update({ where: { id: lauf.id }, data: { freigegebenVonId: admin.id, freigegebenAm: new Date() }, }), ).rejects.toThrow(/Lauf_freigabe_nicht_selbst/u); }); it("verlangt Freigeber und Zeitpunkt zusammen", async () => { const { versionId, techniker, redakteur } = await aufbau(true); const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker); await expect( testPrisma.lauf.update({ where: { id: lauf.id }, data: { freigegebenVonId: redakteur.id } }), ).rejects.toThrow(/Lauf_freigabe_mit_zeitpunkt/u); await expect( testPrisma.lauf.update({ where: { id: lauf.id }, data: { freigegebenAm: new Date() } }), ).rejects.toThrow(/Lauf_freigabe_mit_zeitpunkt/u); }); }); describe.runIf(datenbankVerfuegbar)("Zurückweisen", () => { beforeEach(async () => { await datenbankLeeren(); }); it("verlangt eine Begründung", async () => { // Dieselbe Regel wie bei NICHT_ZUTREFFEND und FEHLGESCHLAGEN: wer etwas // zurückgibt, sagt warum. const { laufId, redakteur } = await abgearbeitet(true); await expect(laufZurueckweisen(laufId, redakteur, " ")).rejects.toThrow(/Begründung/u); }); it("setzt den Lauf zurück auf IN_ARBEIT und hält die Notiz fest", async () => { const { laufId, redakteur } = await abgearbeitet(true); const nachher = await laufZurueckweisen(laufId, redakteur, "Prüfsumme fehlt im Protokoll"); expect(nachher.status).toBe(LaufStatus.IN_ARBEIT); expect(nachher.freigabeNotiz).toBe("Prüfsumme fehlt im Protokoll"); expect(nachher.freigegebenVonId).toBeNull(); const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { laufId, aktion: "Lauf zurückgewiesen" }, }); expect(JSON.stringify(eintrag.nachher)).toContain("Prüfsumme fehlt"); }); it("nimmt eine erteilte Freigabe zurück", async () => { const { laufId, redakteur, admin } = await abgearbeitet(true); await laufFreigeben(laufId, redakteur); await laufZurueckweisen(laufId, admin, "Doch nicht in Ordnung"); const zeile = await testPrisma.lauf.findUniqueOrThrow({ where: { id: laufId } }); expect(zeile.freigegebenVonId).toBeNull(); expect(zeile.freigegebenAm).toBeNull(); // Und der Abschluss wartet wieder. await expect(laufAbschliessen(laufId, redakteur)).rejects.toThrow(/Freigabe/u); }); it("holt einen abgeschlossenen Lauf zurück in die Arbeit", async () => { const { laufId, techniker, redakteur, admin } = await abgearbeitet(true); await laufFreigeben(laufId, redakteur); await laufAbschliessen(laufId, techniker); const nachher = await laufZurueckweisen(laufId, admin, "Nacharbeit nötig"); expect(nachher.status).toBe(LaufStatus.IN_ARBEIT); expect(nachher.abgeschlossenAm).toBeNull(); expect(nachher.abgeschlossenVonId).toBeNull(); }); it("verlangt das Recht freigeben", async () => { const { laufId, techniker } = await abgearbeitet(true); await expect(laufZurueckweisen(laufId, techniker, "Grund")).rejects.toThrow(/Recht "freigeben"/u); }); }); describe.runIf(datenbankVerfuegbar)("Die Aufforderung an die Freigabeberechtigten", () => { beforeEach(async () => { await datenbankLeeren(); }); it("geht beim gescheiterten Abschlussversuch hinaus – an die mit dem Recht", async () => { /* * **Der Fall, der die ganze Regel wertlos machen würde:** ein Lauf wartet auf * eine Freigabe, und niemand weiß es. Die Aufforderung entsteht deshalb genau * dort, wo der Bearbeiter „ich bin fertig“ sagt — beim Abschlussversuch. * * Empfänger ist **nicht** der Zuständige, sondern jeder mit dem Recht * `freigeben`. Das ist der einzige Anlass mit diesem Kreis. */ const { laufId, techniker, redakteur, admin } = await abgearbeitet(true); await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler); const zustellungen = await testPrisma.zustellung.findMany({ where: { laufId, anlass: "LAUF_FREIGABE_OFFEN", weg: "MAIL" }, select: { empfaenger: true }, }); const empfaenger = zustellungen.map((zeile) => zeile.empfaenger).sort(); const redZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: redakteur.id } }); const adminZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } }); const tinaZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: techniker.id } }); expect(empfaenger).toEqual([adminZeile.email, redZeile.email].sort()); // Der Techniker hat kein `freigeben` – ihn aufzufordern wäre eine // Aufforderung zu etwas, das der Dienst abweist. expect(empfaenger).not.toContain(tinaZeile.email); }); it("lässt den aus, der an dem Lauf gearbeitet hat", async () => { // Der Redakteur hat das Recht, aber er hat abgearbeitet: er darf nicht // freigeben, also bekommt er auch keine Aufforderung. const { versionId, schrittId, techniker, redakteur, admin } = await aufbau(true); const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT }, redakteur, ); await expect(laufAbschliessen(lauf.id, techniker)).rejects.toThrow(LaufFehler); const zustellungen = await testPrisma.zustellung.findMany({ where: { laufId: lauf.id, anlass: "LAUF_FREIGABE_OFFEN", weg: "MAIL" }, select: { empfaenger: true }, }); const adminZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } }); expect(zustellungen.map((z) => z.empfaenger)).toEqual([adminZeile.email]); }); it("geht bei mehreren Versuchen nur einmal hinaus", async () => { /* * Ein Techniker versucht es mehrmals — und ohne den partiellen Unique-Index * bekäme jeder Berechtigte bei jedem Versuch eine Mail, jede für sich * korrekt. Derselbe Fall wie „überfällig“ in #034, nur schneller erreicht. */ const { laufId, techniker } = await abgearbeitet(true); for (let versuch = 0; versuch < 3; versuch += 1) { await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler); } const anzahl = await testPrisma.zustellung.count({ where: { laufId, anlass: "LAUF_FREIGABE_OFFEN", weg: "MAIL" }, }); // Zwei Berechtigte, drei Versuche – zwei Zeilen. expect(anzahl).toBe(2); }); it("nennt im Rumpf die Regel und keinen Schrittinhalt", async () => { // Der Empfänger ist meist nicht der Bearbeiter und soll nicht raten müssen, // warum er gefragt wird. Schrittinhalte gehören auch hier nicht hinein (#034). const { laufId, techniker } = await abgearbeitet(true); await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler); const zeile = await testPrisma.zustellung.findFirstOrThrow({ where: { laufId, anlass: "LAUF_FREIGABE_OFFEN" }, }); expect(zeile.betreff).toContain("wartet auf Freigabe"); expect(zeile.rumpf).toMatch(/nicht daran gearbeitet/u); expect(zeile.rumpf).not.toContain("Sicherung zurückspielen"); }); }); describe.runIf(datenbankVerfuegbar)("Ein Lauf ohne Freigabepflicht", () => { beforeEach(async () => { await datenbankLeeren(); }); it("verhält sich unverändert", async () => { /* * **Die Zusage an alles Bestehende.** Jede Vorlage und jeder Lauf von vor * diesem Meilenstein hat `freigabePflicht = false`; nichts daran darf sich * ändern, sonst wäre es ein kaputtes Update. */ const { laufId, techniker } = await abgearbeitet(false); const fertig = await laufAbschliessen(laufId, techniker); expect(fertig.status).toBe(LaufStatus.ABGESCHLOSSEN); expect(fertig.freigegebenVonId).toBeNull(); }); it("lässt sich trotzdem freigeben – nach denselben Regeln", async () => { /* * Eine Freigabe an einem Lauf, der keine braucht, ist kein Fehler. Sie folgt * aber denselben Regeln: sonst wäre sie ein Weg, einen Freigeber in die Akte * zu schreiben, der dort nicht hingehört. */ const { laufId, techniker, redakteur } = await abgearbeitet(false); await expect(laufFreigeben(laufId, redakteur)).resolves.toBeDefined(); await expect(laufFreigeben(laufId, techniker)).rejects.toThrow(/Recht "freigeben"/u); }); it("lässt sich nicht zweimal freigeben", async () => { const { laufId, redakteur, admin } = await abgearbeitet(false); await laufFreigeben(laufId, redakteur); await expect(laufFreigeben(laufId, admin)).rejects.toThrow(/bereits freigegeben/u); }); });