Files
Laufzettel/tests/freigabe.test.ts
T
Florian WildeandClaude Opus 5 d30bb6d235
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions
feat(laeufe): Vier-Augen-Freigabe (#040)
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>
2026-10-24 01:14:04 +02:00

385 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});