feat(laeufe): Vier-Augen-Freigabe (#040)
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions

Verlangt eine Vorlagen-Version eine Freigabe, laesst sich ein Lauf erst
abschliessen, wenn ein Zweiter ihn freigegeben hat. Das braucht zweierlei, und
beides zusammen: das neue Recht `freigeben` (ADMIN und REDAKTEUR, nicht
TECHNIKER) und dass man nicht an dem Lauf gearbeitet hat.

Als gearbeitet zaehlt: angelegt, einen Schritt gesetzt oder abgeschlossen. Eine
Zuweisung allein nicht — waere die Regel hier strenger, blockierte sie kleine
Teams ohne Gewinn.

Auch ein Administrator kann sich nicht selbst freigeben, und das ist der ganze
Sinn. Die Pruefung sitzt in `hatDaranGearbeitet` an einer Stelle, wie
`laufMussVeraenderbarSein`; die wichtigste Haelfte — der Ersteller — haelt
zusaetzlich ein CHECK in der Datenbank.

Zurueckweisen verlangt eine Begruendung, setzt den Lauf auf IN_ARBEIT und nimmt
eine erteilte Freigabe zurueck. Kein fuenfter LaufStatus: ein neuer Enum-Wert
haette jeden Codepfad betroffen, der heute vier Faelle kennt.

Sobald jemand abzuschliessen versucht und die Freigabe fehlt, bekommen die
Freigabeberechtigten eine Mail — ein anderer Empfaengerkreis als bei jeder
anderen Meldung, und ohne die, die gearbeitet haben. Die Aufforderung entsteht
ausserhalb der Transaktion: innerhalb wuerde sie mit dem Fehler zurueckgerollt,
und dann wartet ein Lauf auf etwas, von dem niemand weiss. Ein partieller
Unique-Index haelt sie bei mehreren Versuchen auf einmal.

Die Pflicht haengt an der Version, nicht an der Vorlage: eine veroeffentlichte
Version ist unveraenderlich, ein laufender Lauf behaelt also seine Regel. Beim
Bearbeiten und Duplizieren wandert sie mit — das war die unmittelbare Lehre aus
#039, wo die tiefe Kopie zwei Felder stillschweigend verworfen hatte.

1526 Vitest-Tests (vorher 1496), 115 E2E-Ablaeufe gruen. Klickprobe am gebauten
Stand mit elf Pruefungen; drei rote Punkte darin waren meine eigenen
Testfehler, einer davon lehrreich: ein reload() direkt nach einer Server Action
trifft den Router-Cache von Next und zeigt kurz den alten Stand.

Closes #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-24 01:14:04 +02:00
co-authored by Claude Opus 5
parent a2330ef682
commit d30bb6d235
27 changed files with 1373 additions and 44 deletions
+384
View File
@@ -0,0 +1,384 @@
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);
});
});