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>
385 lines
16 KiB
TypeScript
385 lines
16 KiB
TypeScript
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);
|
||
});
|
||
});
|