import { readFileSync } from "node:fs"; import { beforeEach, describe, expect, it } from "vitest"; import { SchrittZustand, VariablenTyp, VersionStatus } from "@/generated/prisma/enums"; import { laufZumDrucken } from "@/server/queries/druck"; import { laufZumAbarbeiten } from "@/server/queries/laeufe"; import { LaufFehler, laufAbschliessen, laufAnlegen } from "@/server/services/laeufe"; import { versionMitAufbau } from "@/server/queries/vorlagen"; import { entwurfHolenOderErzeugen, entwurfSpeichern, versionVeroeffentlichen, vorlageAnlegen, vorlageDuplizieren, } from "@/server/services/vorlagen"; import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Bedingte Abschnitte und Schritte gegen die Datenbank (#039). * * Die reine Auswertung steht in `tests/bedingungen.test.ts`. Hier geht es um die * Frage, die eine reine Funktion nicht beantworten kann: **greift sie überall?** * * Drei Schichten brauchen sie — der Dienst beim Abschluss, das Lesemodell der * Abarbeitung und das der Druckansicht. Vergisst eine davon die Auswertung, * sieht der Techniker andere Schritte als der Ausdruck, oder ein unsichtbarer * Pflichtschritt hält den Abschluss auf. Beides fällt in einer Suite nicht auf, * die nur die Logik prüft. */ type AbschnittsVorgabe = { readonly titel: string; readonly bedingung?: unknown; readonly schritte: readonly { readonly titel: string; readonly pflicht?: boolean; readonly bedingung?: unknown; }[]; }; /** * Legt eine Vorlage an und veröffentlicht sie. * * `nachtragen` wird mit den Schritt-Kennungen gerufen, **bevor** veröffentlicht * wird — genau wie im Editor: eine Bedingung auf einem Schritt braucht dessen * Kennung, und eine veröffentlichte Version ist unveränderlich (#009). Der * Trigger `…_bedingung_gleiche_version` verlangt zusätzlich, dass der Schritt * zur selben Version gehört. */ async function aufbau( abschnitte: readonly AbschnittsVorgabe[], nachtragen?: (schrittIds: ReadonlyMap, versionId: string) => Promise, ) { const kunde = await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global" } }); const admin = await testPrisma.benutzer.create({ data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" }, }); const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } }); const version = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF }, }); await testPrisma.vorlagenVariable.create({ data: { versionId: version.id, schluessel: "konto_typ", beschriftung: "Kontotyp", typ: VariablenTyp.AUSWAHL, optionen: ["AD", "ENTRA"], position: 0, }, }); const schrittIds = new Map(); for (const [position, abschnitt] of abschnitte.entries()) { const angelegt = await testPrisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: abschnitt.titel, position, ...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung as never }), }, }); for (const [stelle, schritt] of abschnitt.schritte.entries()) { const angelegterSchritt = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: angelegt.id, titel: schritt.titel, pflicht: schritt.pflicht ?? false, position: stelle, ...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung as never }), }, }); schrittIds.set(schritt.titel, angelegterSchritt.id); } } await nachtragen?.(schrittIds, version.id); await testPrisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, }); return { kunde, admin: { id: admin.id, rolle: admin.rolle }, versionId: version.id, schrittIds, }; } /** Der echte Fall aus dem Seed: beide Konto-Abschnitte, eine Variable entscheidet. */ const BEIDE_KONTEN: readonly AbschnittsVorgabe[] = [ { titel: "Vorbereitung", schritte: [{ titel: "Papiere", pflicht: true }] }, { titel: "Konto anlegen (Active Directory)", bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" }, schritte: [ { titel: "New-ADUser", pflicht: true }, { titel: "Gruppen setzen", pflicht: true }, ], }, { titel: "Konto anlegen (Entra ID)", bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "ENTRA" }, schritte: [{ titel: "New-MgUser", pflicht: true }], }, ]; const titel = (abschnitte: readonly { readonly titel: string }[]): readonly string[] => abschnitte.map((abschnitt) => abschnitt.titel); describe.runIf(datenbankVerfuegbar)("Ein Lauf mit bedingten Abschnitten", () => { beforeEach(async () => { await datenbankLeeren(); }); it("zeigt bei konto_typ = AD nur den AD-Abschnitt", async () => { /* * **Der Fall, für den dieser Meilenstein da ist.** Die Onboarding-Vorlage im * Seed trägt beide Konto-Abschnitte, und `konto_typ` sagt längst, welcher * gilt — bis hierher wertete das niemand aus, und ein Techniker klickte neun * Schritte weg. */ const { admin, versionId } = await aufbau(BEIDE_KONTEN); const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin); const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester"); expect(titel(daten!.abschnitte)).toEqual(["Vorbereitung", "Konto anlegen (Active Directory)"]); }); it("zeigt bei konto_typ = ENTRA nur den Entra-Abschnitt", async () => { const { admin, versionId } = await aufbau(BEIDE_KONTEN); const lauf = await laufAnlegen( { vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } }, admin, ); const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester"); expect(titel(daten!.abschnitte)).toEqual(["Vorbereitung", "Konto anlegen (Entra ID)"]); }); it("zeigt bei nicht gesetzter Variable beide – und blendet nichts aus", async () => { /* * Eine Bedingung, die sich nicht entscheiden lässt, blendet nichts aus. Das * ist genau das Verhalten von **vor** diesem Meilenstein, für jeden Lauf — * eine Verschlechterung ist es also nicht. Eine erfundene Antwort wäre eine. */ const { admin, versionId } = await aufbau(BEIDE_KONTEN); const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin); const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester"); expect(titel(daten!.abschnitte)).toEqual([ "Vorbereitung", "Konto anlegen (Active Directory)", "Konto anlegen (Entra ID)", ]); }); it("nummeriert die sichtbaren Abschnitte lückenlos", async () => { /* * `01 · Vorbereitung` zählt, was der Techniker **sieht**. Ein ausgeblendeter * Abschnitt darf keine Lücke hinterlassen — die Nummer kommt aus der * Position und wird nie gespeichert (CLAUDE.md, „Gestaltung“). */ const { admin, versionId } = await aufbau(BEIDE_KONTEN); const lauf = await laufAnlegen( { vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } }, admin, ); const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester"); expect(daten!.abschnitte.map((abschnitt) => abschnitt.nummer)).toEqual(["01", "02"]); }); it("hält den Abschluss nicht an einem ausgeblendeten Pflichtschritt auf", async () => { /* * **Die zweite Kernzusage.** Der Entra-Abschnitt ist ausgeblendet, sein * Schritt ist Pflicht. Blockierte er, wäre der Lauf unabschließbar: abhaken * lässt sich nur, was in der Abarbeitung steht. */ const { admin, versionId, schrittIds } = await aufbau(BEIDE_KONTEN); const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin); for (const name of ["Papiere", "New-ADUser", "Gruppen setzen"]) { await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schrittIds.get(name)!, zustand: SchrittZustand.ERLEDIGT }, admin, ); } await expect(laufAbschliessen(lauf.id, admin)).resolves.toBeDefined(); }); it("zeigt im Druck genau dieselben Abschnitte wie in der Abarbeitung", async () => { /* * Ein Ausdruck, der andere Schritte zeigt als der Bildschirm, ist das * Gegenteil eines Beweismittels. Geprüft wird deshalb gegen die **andere** * Schicht und nicht gegen eine erwartete Liste. */ const { admin, versionId } = await aufbau(BEIDE_KONTEN); const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin); const abarbeitung = await laufZumAbarbeiten(lauf.id, admin, "Tester"); const druck = await laufZumDrucken(lauf.id, admin); expect(titel(druck!.abschnitte)).toEqual(titel(abarbeitung!.abschnitte)); }); }); describe.runIf(datenbankVerfuegbar)("Eine Bedingung auf einem Schrittergebnis", () => { beforeEach(async () => { await datenbankLeeren(); }); /** * Setzt am Abschnitt „Nacharbeit“ die Bedingung „Prüfen ist fehlgeschlagen“. * * Als Nachtrag im **Entwurf**, weil die Bedingung die Kennung des Schritts * braucht und der erst beim Anlegen entsteht. Danach wird veröffentlicht, und * danach ändert sich an der Version nichts mehr (#009). */ const nacharbeitWennFehlgeschlagen = async ( schrittIds: ReadonlyMap, versionId: string, ): Promise => { await testPrisma.vorlagenAbschnitt.updateMany({ where: { versionId, titel: "Nacharbeit" }, data: { bedingung: { art: "schritt", schrittId: schrittIds.get("Prüfen")!, zustaende: ["FEHLGESCHLAGEN"], }, }, }); }; it("erscheint erst, wenn die Bedingung während des Laufs umschlägt", async () => { /* * **Der Grund, warum laufend ausgewertet wird und nicht beim Start.** Die * Antwort auf „ist Prüfen fehlgeschlagen?“ entsteht erst während der Arbeit; * festgeschrieben wie bei #031 wäre sie beim Start immer „nein“. */ // Erst anlegen, um die Schritt-Kennung zu bekommen, dann die Bedingung setzen: // der Trigger verlangt einen Schritt derselben Version. const { admin, versionId, schrittIds } = await aufbau( [ { titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] }, { titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen", pflicht: true }] }, ], nacharbeitWennFehlgeschlagen, ); const pruefenId = schrittIds.get("Prüfen")!; const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin); // Zu Beginn ist „Prüfen“ OFFEN. Das ist eine **Antwort**, nicht eine Lücke: // der Abschnitt gilt nicht. expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual(["Prüfung"]); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "Sicherung nicht lesbar", }, admin, ); expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual([ "Prüfung", "Nacharbeit", ]); }); it("lässt ein Schrittergebnis mit Notiz stehen, wenn seine Bedingung umschlägt", async () => { /* * Ausgeblendet heißt **nicht** gelöscht. Jemand hat eine Notiz geschrieben; * sie zu verlieren, weil eine Bedingung kippt, wäre Datenverlust durch eine * Anzeigeregel. Schlägt die Bedingung zurück, ist alles wieder da. */ const { admin, versionId, schrittIds } = await aufbau( [ { titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] }, { titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen" }] }, ], nacharbeitWennFehlgeschlagen, ); const pruefenId = schrittIds.get("Prüfen")!; const ticketId = schrittIds.get("Ticket aufmachen")!; const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "x" }, admin, ); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: ticketId, zustand: SchrittZustand.ERLEDIGT, notiz: "TIC-4711" }, admin, ); // Bedingung kippt: der Abschnitt verschwindet aus der Ansicht … await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.ERLEDIGT }, admin, ); expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual(["Prüfung"]); // … die Zeile bleibt aber in der Datenbank. const zeile = await testPrisma.schrittergebnis.findFirstOrThrow({ where: { laufId: lauf.id, vorlagenSchrittId: ticketId }, }); expect(zeile.notiz).toBe("TIC-4711"); // Und schlägt sie zurück, ist alles wieder da. await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "y" }, admin, ); const wieder = await laufZumAbarbeiten(lauf.id, admin, "T"); expect(titel(wieder!.abschnitte)).toEqual(["Prüfung", "Nacharbeit"]); expect(wieder!.abschnitte[1]?.schritte[0]?.notiz).toBe("TIC-4711"); }); it("hält den Abschluss auf, solange ein sichtbarer Pflichtschritt offen ist", async () => { // Die Gegenrichtung, und sie ist nötig: eine Abschlussregel, die **immer** // durchlässt, sähe in den Tests oben genauso aus. const { admin, versionId, schrittIds } = await aufbau( [ { titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] }, { titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen", pflicht: true }] }, ], nacharbeitWennFehlgeschlagen, ); const pruefenId = schrittIds.get("Prüfen")!; const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "x" }, admin, ); // „Ticket aufmachen“ ist jetzt sichtbar und Pflicht – der Abschluss wartet. await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(LaufFehler); await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/Ticket aufmachen/u); }); }); describe.runIf(datenbankVerfuegbar)("Eine Bedingung an einem einzelnen Schritt", () => { beforeEach(async () => { await datenbankLeeren(); }); it("blendet den Schritt aus, nicht den ganzen Abschnitt", async () => { const { admin, versionId } = await aufbau([ { titel: "Konto", schritte: [ { titel: "Immer" }, { titel: "Nur bei AD", pflicht: true, bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" }, }, ], }, ]); const lauf = await laufAnlegen( { vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } }, admin, ); const daten = await laufZumAbarbeiten(lauf.id, admin, "T"); expect(titel(daten!.abschnitte)).toEqual(["Konto"]); expect(daten!.abschnitte[0]?.schritte.map((schritt) => schritt.titel)).toEqual(["Immer"]); // Und der ausgeblendete Pflichtschritt hält den Abschluss nicht auf. expect(daten!.anzahlPflichtOffen).toBe(0); }); }); describe.runIf(datenbankVerfuegbar)("Der Weg durch den Editor", () => { beforeEach(async () => { await datenbankLeeren(); }); it("übersteht Speichern, Neuladen und Veröffentlichen", async () => { /* * **Der Weg, auf dem eine Bedingung verloren gehen würde.** * `entwurfSpeichern` löscht den Entwurf und schreibt ihn neu — die Schritte * bekommen dabei neue Kennungen. Eine Bedingung, die auf eine Kennung zeigt, * überlebt das nicht; in der Struktur steht deshalb ein **Titel-Pfad**, so * wie in einer Austauschdatei, und derselbe Code löst beide auf. * * Dreimal gespeichert, weil einmal nichts beweist: der Pfad muss **jedes** * Neuschreiben überstehen. */ const kunde = await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global" } }); const redakteur = await testPrisma.benutzer.create({ data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" }, }); const handelnder = { id: redakteur.id, rolle: redakteur.rolle }; const { vorlage } = await vorlageAnlegen({ name: "Mit Bedingung" }, redakteur.id); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id); const struktur = { variablen: [ { schluessel: "konto_typ", beschriftung: "Kontotyp", typ: "AUSWAHL", optionen: ["AD", "ENTRA"] }, ], abschnitte: [ { titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] }, { titel: "Nacharbeit", bedingung: { art: "schritt", abschnitt: "Prüfung", schritt: "Prüfen", zustaende: ["FEHLGESCHLAGEN"], }, schritte: [ { titel: "Nur bei AD", bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" }, }, ], }, ], } as const; for (let runde = 1; runde <= 3; runde += 1) { await entwurfSpeichern(entwurf.id, struktur as never); // Zurückgelesen steht wieder der Pfad da, nicht die Kennung. const gelesen = await versionMitAufbau(entwurf.id, handelnder); expect(gelesen!.struktur.abschnitte[1]?.bedingung, `Runde ${runde}`).toEqual({ art: "schritt", abschnitt: "Prüfung", schritt: "Prüfen", zustaende: ["FEHLGESCHLAGEN"], }); expect(gelesen!.struktur.abschnitte[1]?.schritte[0]?.bedingung).toEqual({ art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD", }); // Und in der Datenbank steht die **aktuelle** Kennung. const zeilen = await testPrisma.vorlagenAbschnitt.findMany({ where: { versionId: entwurf.id }, orderBy: { position: "asc" }, include: { schritte: true }, }); expect(zeilen[1]?.bedingung).toMatchObject({ art: "schritt", schrittId: zeilen[0]!.schritte[0]!.id, }); } // Veröffentlichen lässt die Bedingung stehen – und ein Lauf wertet sie aus. await versionVeroeffentlichen(entwurf.id, null, redakteur.id); const lauf = await laufAnlegen( { vorlagenVersionId: entwurf.id, kundeId: kunde.id, variablen: { konto_typ: "AD" } }, handelnder, ); expect(titel((await laufZumAbarbeiten(lauf.id, handelnder, "T"))!.abschnitte)).toEqual(["Prüfung"]); }); it("lässt eine Bedingung weg, deren Titel-Pfad es nicht gibt", async () => { /* * Ein Abschnitt, dessen Zielschritt gelöscht wurde: beim nächsten Speichern * findet der Pfad nichts. Weggelassen heißt „gilt immer“ — sichtbar in der * Oberfläche. Eine Bedingung mit einem Verweis ins Leere wäre dasselbe * Ergebnis, nur unerklärlich. */ await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global" } }); const redakteur = await testPrisma.benutzer.create({ data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" }, }); const { vorlage } = await vorlageAnlegen({ name: "Verwaister Verweis" }, redakteur.id); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id); await entwurfSpeichern(entwurf.id, { variablen: [], abschnitte: [ { titel: "Eins", schritte: [{ titel: "A" }] }, { titel: "Zwei", bedingung: { art: "schritt", abschnitt: "Gibt es nicht", schritt: "Auch nicht", zustaende: ["ERLEDIGT"], }, schritte: [{ titel: "B" }], }, ], } as never); const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({ where: { versionId: entwurf.id, titel: "Zwei" }, }); expect(zeile.bedingung).toBeNull(); }); }); describe.runIf(datenbankVerfuegbar)("Die tiefe Kopie", () => { beforeEach(async () => { await datenbankLeeren(); }); /** * Legt eine veröffentlichte Version mit **allen** Struktureigenschaften an. * * Absichtlich vollgepackt: der Test darunter vergleicht Kopie und Original * Feld für Feld, und was hier fehlt, prüft er nicht. */ async function reicheVersion() { await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global" } }); const redakteur = await testPrisma.benutzer.create({ data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" }, }); const { vorlage } = await vorlageAnlegen({ name: "Vollgepackt" }, redakteur.id); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id); await entwurfSpeichern(entwurf.id, { variablen: [ { schluessel: "konto_typ", beschriftung: "Kontotyp", typ: "AUSWAHL", optionen: ["AD", "ENTRA"] }, ], abschnitte: [ { titel: "Prüfung", nurFuerAbteilungen: ["Buchhaltung"], schritte: [ { titel: "Prüfen", pflicht: true, hilfetext: "Mit Blick aufs Protokoll.", dokuUrl: "https://intern.example/pruefen", typ: "ZAHL", konfig: { min: 1, max: 9, einheit: "GB" }, }, ], }, { titel: "Nacharbeit", nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"], bedingung: { art: "schritt", abschnitt: "Prüfung", schritt: "Prüfen", zustaende: ["FEHLGESCHLAGEN"], }, schritte: [ { titel: "Nur bei AD", bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" }, }, ], }, ], } as never); await versionVeroeffentlichen(entwurf.id, "Erste", redakteur.id); return { vorlageId: vorlage.id, versionId: entwurf.id, redakteurId: redakteur.id }; } /** Die Struktur einer Version, ohne alles, was sich bei einer Kopie ändern darf. */ async function struktur(versionId: string) { const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({ where: { versionId }, orderBy: { position: "asc" }, include: { schritte: { orderBy: { position: "asc" } } }, }); // Kennungen wandern bei einer Kopie – verglichen wird der Inhalt. const ohneKennung = (bedingung: unknown): unknown => { if (typeof bedingung !== "object" || bedingung === null) return bedingung; const kopie = { ...(bedingung as Record) }; if ("schrittId" in kopie) kopie["schrittId"] = ""; return kopie; }; return abschnitte.map((abschnitt) => ({ titel: abschnitt.titel, nurFuerAbteilungen: abschnitt.nurFuerAbteilungen, bedingung: ohneKennung(abschnitt.bedingung), schritte: abschnitt.schritte.map((schritt) => ({ titel: schritt.titel, hilfetext: schritt.hilfetext, typ: schritt.typ, pflicht: schritt.pflicht, konfig: schritt.konfig, dokuUrl: schritt.dokuUrl, bedingung: ohneKennung(schritt.bedingung), })), })); } it("ist bei „Bearbeiten“ inhaltsgleich – mit allem", async () => { /* * **Der Test, der zwei stille Datenverluste gefunden hätte.** Die tiefe * Kopie nahm `nurFuerAbteilungen` (seit #031) und `bedingung` (seit #039) * nicht mit: „Bearbeiten“ verlor alle Abteilungseinschränkungen, die neue * Version galt für jeden Lauf. Also mehr Schritte statt weniger, und niemand * merkt es. * * Die Ursache war ein von Hand getippter Zwischentyp, der weniger Felder * nannte als die Abfrage liefert. Gefunden erst bei der Klickprobe am * gebauten Stand. * * Verglichen wird deshalb **Feld für Feld und nicht stichprobenweise**: so * fällt auch das nächste Feld auf, das jemand am Modell ergänzt und beim * Kopieren vergisst. */ const { vorlageId, versionId, redakteurId } = await reicheVersion(); const vorher = await struktur(versionId); // „Bearbeiten“ in der Oberfläche ruft `entwurfHolenOderErzeugen`: das ist // die tiefe Kopie der letzten veröffentlichten Version (#009). const entwurf = await entwurfHolenOderErzeugen(vorlageId, redakteurId); expect(await struktur(entwurf.id)).toEqual(vorher); }); it("ist beim Duplizieren inhaltsgleich – mit allem", async () => { // Dieselbe Prüfung für den zweiten Weg: er hatte denselben Fehler, weil er // dieselbe Kopie ausgeschrieben hatte. Jetzt teilen beide eine Stelle. const { vorlageId, versionId, redakteurId } = await reicheVersion(); const vorher = await struktur(versionId); const kopie = await vorlageDuplizieren(vorlageId, "Vollgepackt (Kopie)", redakteurId); const neueVersion = await testPrisma.vorlagenVersion.findFirstOrThrow({ where: { vorlageId: kopie.id }, }); expect(await struktur(neueVersion.id)).toEqual(vorher); }); it("schreibt die Schritt-Bedingung auf die neue Kennung um", async () => { /* * Die Kennungen sind neu; ein Verweis auf die alte wäre nach dem * Veröffentlichen nicht mehr entscheidbar, und der Abschnitt erschiene * stillschweigend immer. Der Trigger in der Datenbank verlangt dasselbe. */ const { vorlageId, redakteurId } = await reicheVersion(); const entwurf = await entwurfHolenOderErzeugen(vorlageId, redakteurId); const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({ where: { versionId: entwurf.id }, orderBy: { position: "asc" }, include: { schritte: true }, }); expect(abschnitte[1]?.bedingung).toMatchObject({ art: "schritt", // Die Kennung des Schritts der **neuen** Version. schrittId: abschnitte[0]!.schritte[0]!.id, }); }); }); describe("Die Auswertung sitzt an jeder Stelle, die filtert", () => { it("ruft geltendeStruktur überall, wo nurGeltende gerufen wird", () => { /* * **Strukturell geprüft, weil eine vergessene Stelle nicht auffällt.** Drei * Schichten dampfen die Struktur eines Laufs ein: der Dienst beim Abschluss, * die Abarbeitung und die Druckansicht. Vergisst eine davon die Bedingungen, * sieht der Techniker andere Schritte als der Ausdruck — oder ein * unsichtbarer Pflichtschritt hält den Abschluss auf. * * Dieselbe Art Test wie „jede Seite prüft selbst“ (#038): die Zusage steht * nicht in einer Prüfung, sondern in der Vollständigkeit einer Liste, und die * muss jemand bewachen. */ const dateien = [ "src/server/services/laeufe.ts", "src/server/queries/laeufe.ts", "src/server/queries/druck.ts", ]; const befunde: string[] = []; for (const datei of dateien) { const quelle = readFileSync(datei, "utf8"); if (!quelle.includes("nurGeltende(")) { befunde.push(`${datei} ruft nurGeltende nicht mehr – ist die Liste noch richtig?`); continue; } if (!quelle.includes("geltendeStruktur(")) { befunde.push(`${datei} filtert nach #031, wertet aber keine Bedingungen aus (#039).`); } } expect(befunde).toEqual([]); }); });