import { beforeEach, describe, expect, it } from "vitest"; import { VorlagenFehler, entwurfHolenOderErzeugen, entwurfSpeichern, entwurfVerwerfen, veroeffentlichteVersionPruefen, versionMussEntwurfSein, versionVeroeffentlichen, vorlageAnlegen, vorlageArchivieren, vorlageBearbeiten, vorlageDuplizieren, } from "@/server/services/vorlagen"; import type { StrukturEingabe } from "@/lib/vorlagen-format"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Versionierung der Vorlagen (#009). * * Die tragende Regel: eine veroeffentlichte Version wird nie wieder veraendert. * Ein Lauf zeigt deshalb auch in zehn Jahren genau die Schritte, die damals * abgearbeitet wurden. Alles hier dreht sich darum, dass diese Regel haelt. */ const STRUKTUR: StrukturEingabe = { variablen: [ { schluessel: "os_version", beschriftung: "Betriebssystem", typ: "AUSWAHL", optionen: ["Windows 11 Pro", "Windows 11 Enterprise"], pflicht: true, }, { schluessel: "join_typ", beschriftung: "Einbindung", typ: "AUSWAHL", optionen: ["Active Directory", "Entra ID"], pflicht: true, }, ], abschnitte: [ { titel: "Vorbereitung", schritte: [ { titel: "Ticket und Endbenutzer im Lauf erfasst", pflicht: true }, { titel: "Datensicherung geprüft", hilfetext: "Auch nach lokal gespeicherten Daten außerhalb des Profils fragen.", pflicht: true, }, ], }, { titel: "Installation", schritte: [ { titel: "Hostname gesetzt", typ: "BEFEHL", pflicht: true, konfig: { shell: "powershell", befehl: 'Rename-Computer -NewName "{{hostname}}" -Restart' }, }, { titel: "Build-Nummer", typ: "TEXT", pflicht: true, konfig: { mehrzeilig: false } }, ], }, ], }; async function benutzerAnlegen(email = "redakteur@firma.example") { return testPrisma.benutzer.create({ data: { email, name: "Redakteur", rolle: "REDAKTEUR" }, }); } /** Eine Vorlage mit einer veroeffentlichten Version 1. */ async function veroeffentlichteVorlage(benutzerId: string) { const { vorlage, version } = await vorlageAnlegen( { name: "PC Neuinstallation", kategorie: "Client" }, benutzerId, ); await entwurfSpeichern(version.id, STRUKTUR); await versionVeroeffentlichen(version.id, "Erste Fassung", benutzerId); return { vorlage, versionId: version.id }; } async function strukturLesen(versionId: string) { const version = await testPrisma.vorlagenVersion.findUniqueOrThrow({ where: { id: versionId }, include: { variablen: { orderBy: { position: "asc" } }, abschnitte: { orderBy: { position: "asc" }, include: { schritte: { orderBy: { position: "asc" } } }, }, }, }); return version; } describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => { beforeEach(async () => { await datenbankLeeren(); }); describe("Anlegen", () => { it("legt eine Vorlage mit einem leeren Entwurf als Version 1 an", async () => { const benutzer = await benutzerAnlegen(); const { vorlage, version } = await vorlageAnlegen( { name: " Mitarbeiter-Onboarding ", kategorie: "Benutzer", schlagworte: ["ad", "entra"] }, benutzer.id, ); expect(vorlage.name).toBe("Mitarbeiter-Onboarding"); expect(vorlage.schlagworte).toEqual(["ad", "entra"]); expect(version.version).toBe(1); expect(version.status).toBe("ENTWURF"); expect(version.veroeffentlichtAm).toBeNull(); }); it("weist einen leeren Namen ab", async () => { const benutzer = await benutzerAnlegen(); await expect(vorlageAnlegen({ name: " " }, benutzer.id)).rejects.toBeInstanceOf(VorlagenFehler); expect(await testPrisma.vorlage.count()).toBe(0); }); }); describe("Entwurf speichern", () => { it("legt Variablen, Abschnitte und Schritte in der übergebenen Reihenfolge an", async () => { const benutzer = await benutzerAnlegen(); const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id); await entwurfSpeichern(version.id, STRUKTUR); const gelesen = await strukturLesen(version.id); expect(gelesen.variablen.map((v) => v.schluessel)).toEqual(["os_version", "join_typ"]); expect(gelesen.variablen.map((v) => v.position)).toEqual([0, 1]); expect(gelesen.abschnitte.map((a) => a.titel)).toEqual(["Vorbereitung", "Installation"]); expect(gelesen.abschnitte[1]?.schritte.map((s) => s.titel)).toEqual([ "Hostname gesetzt", "Build-Nummer", ]); expect(gelesen.abschnitte[1]?.schritte[0]?.konfig).toMatchObject({ shell: "powershell", befehl: 'Rename-Computer -NewName "{{hostname}}" -Restart', }); }); it("ersetzt den Aufbau vollständig, statt ihn zu ergänzen", async () => { const benutzer = await benutzerAnlegen(); const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id); await entwurfSpeichern(version.id, STRUKTUR); await entwurfSpeichern(version.id, { variablen: [], abschnitte: [{ titel: "Nur noch das hier", schritte: [{ titel: "Ein Schritt" }] }], }); const gelesen = await strukturLesen(version.id); expect(gelesen.variablen).toHaveLength(0); expect(gelesen.abschnitte).toHaveLength(1); expect(await testPrisma.vorlagenSchritt.count()).toBe(1); }); it("weist einen doppelten Variablenschlüssel mit verständlicher Meldung ab", async () => { const benutzer = await benutzerAnlegen(); const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id); await expect( entwurfSpeichern(version.id, { variablen: [ { schluessel: "upn", beschriftung: "UPN" }, { schluessel: "upn", beschriftung: "Nochmal UPN" }, ], abschnitte: [], }), ).rejects.toThrow(/„upn“ gibt es zweimal/); }); it("nennt die Stelle so, wie sie im Editor steht", async () => { // „abschnitte.0.schritte.1.titel“ ist richtig und nutzlos: der Redakteur // muesste von null an zaehlen. Der Review hat es als den Weg benannt, auf // dem Arbeit wirklich verloren geht — mehrfach vergeblich speichern, dann // weggehen. const benutzer = await benutzerAnlegen(); const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id); await expect( entwurfSpeichern(version.id, { variablen: [], abschnitte: [{ titel: "Abschnitt", schritte: [{ titel: "gut" }, { titel: " " }] }], }), ).rejects.toThrow("Abschnitt 01, Schritt 2: Ein Schritt braucht einen Titel."); }); it("hinterlässt bei einem Fehler keinen halben Entwurf", async () => { const benutzer = await benutzerAnlegen(); const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id); await entwurfSpeichern(version.id, STRUKTUR); await expect( entwurfSpeichern(version.id, { variablen: [], abschnitte: [{ titel: "", schritte: [] }], }), ).rejects.toThrow(); // Der alte Aufbau steht unveraendert. const gelesen = await strukturLesen(version.id); expect(gelesen.abschnitte).toHaveLength(2); }); }); describe("Unveränderlichkeit", () => { it("lässt eine veröffentlichte Version nicht mehr bearbeiten", async () => { const benutzer = await benutzerAnlegen(); const { versionId } = await veroeffentlichteVorlage(benutzer.id); await expect(entwurfSpeichern(versionId, { variablen: [], abschnitte: [] })).rejects.toThrow( /unveränderlich/, ); }); it("lässt eine veröffentlichte Version nicht erneut veröffentlichen", async () => { const benutzer = await benutzerAnlegen(); const { versionId } = await veroeffentlichteVorlage(benutzer.id); await expect(versionVeroeffentlichen(versionId, null, benutzer.id)).rejects.toThrow(/unveränderlich/); }); it("lässt eine veröffentlichte Version nicht verwerfen", async () => { const benutzer = await benutzerAnlegen(); const { versionId } = await veroeffentlichteVorlage(benutzer.id); await expect(entwurfVerwerfen(versionId, benutzer.id)).rejects.toThrow(/unveränderlich/); }); it("prüft den Zustand an einer Stelle", () => { expect(() => versionMussEntwurfSein({ status: "ENTWURF", version: 1 })).not.toThrow(); expect(() => versionMussEntwurfSein({ status: "VEROEFFENTLICHT", version: 3 })).toThrow(/Version 3/); }); }); describe("Bearbeiten erzeugt eine tiefe Kopie", () => { it("kopiert Variablen, Abschnitte und Schritte, statt sie zu teilen", async () => { const benutzer = await benutzerAnlegen(); const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); expect(entwurf.version).toBe(2); expect(entwurf.status).toBe("ENTWURF"); const veroeffentlicht = await strukturLesen(versionId); const kopie = await strukturLesen(entwurf.id); // Gleicher Inhalt … expect(kopie.variablen.map((v) => v.schluessel)).toEqual( veroeffentlicht.variablen.map((v) => v.schluessel), ); expect(kopie.abschnitte.map((a) => a.titel)).toEqual(veroeffentlicht.abschnitte.map((a) => a.titel)); // … aber eigene Zeilen. const alteIds = veroeffentlicht.abschnitte.map((a) => a.id); for (const abschnitt of kopie.abschnitte) { expect(alteIds).not.toContain(abschnitt.id); } }); it("lässt die veröffentlichte Version unberührt, wenn der Entwurf geändert wird", async () => { const benutzer = await benutzerAnlegen(); const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); await entwurfSpeichern(entwurf.id, { variablen: [], abschnitte: [{ titel: "Komplett anders", schritte: [{ titel: "Neu" }] }], }); const veroeffentlicht = await strukturLesen(versionId); expect(veroeffentlicht.abschnitte.map((a) => a.titel)).toEqual(["Vorbereitung", "Installation"]); expect(veroeffentlicht.variablen).toHaveLength(2); }); it("kopiert auch die konfig als Wert, nicht als geteilten Verweis", async () => { const benutzer = await benutzerAnlegen(); const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); const kopie = await strukturLesen(entwurf.id); const befehlsschritt = kopie.abschnitte[1]?.schritte[0]; expect(befehlsschritt?.konfig).toMatchObject({ shell: "powershell" }); await testPrisma.vorlagenSchritt.update({ where: { id: befehlsschritt!.id }, data: { konfig: { shell: "bash", befehl: "echo neu" } }, }); const original = await strukturLesen(versionId); expect(original.abschnitte[1]?.schritte[0]?.konfig).toMatchObject({ shell: "powershell" }); }); it("gibt einen vorhandenen Entwurf zurück, statt einen zweiten anzulegen", async () => { const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); const erst = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); const nochmal = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); expect(nochmal.id).toBe(erst.id); expect( await testPrisma.vorlagenVersion.count({ where: { vorlageId: vorlage.id, status: "ENTWURF" }, }), ).toBe(1); }); it("lässt eine archivierte Vorlage nicht bearbeiten", async () => { const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); await vorlageArchivieren(vorlage.id, true, benutzer.id); await expect(entwurfHolenOderErzeugen(vorlage.id, benutzer.id)).rejects.toThrow(/archiviert/); }); }); describe("Veröffentlichen", () => { it("setzt Status, Zeitpunkt, Änderungsnotiz und Urheber", async () => { const benutzer = await benutzerAnlegen(); const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id); await entwurfSpeichern(version.id, STRUKTUR); const veroeffentlicht = await versionVeroeffentlichen( version.id, " Secure Boot ergänzt ", benutzer.id, ); expect(veroeffentlicht.status).toBe("VEROEFFENTLICHT"); expect(veroeffentlicht.veroeffentlichtAm).not.toBeNull(); expect(veroeffentlicht.veroeffentlichtVonId).toBe(benutzer.id); expect(veroeffentlicht.aenderungsnotiz).toBe("Secure Boot ergänzt"); }); it("hält das Veröffentlichen im Protokoll fest", async () => { const benutzer = await benutzerAnlegen(); const { versionId } = await veroeffentlichteVorlage(benutzer.id); const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { entitaetId: versionId, aktion: "Version veröffentlicht" }, }); expect(eintrag.benutzerId).toBe(benutzer.id); expect(eintrag.nachher).toMatchObject({ status: "VEROEFFENTLICHT" }); }); it("weist eine Vorlage ohne Abschnitte ab", async () => { const benutzer = await benutzerAnlegen(); const { version } = await vorlageAnlegen({ name: "Leer" }, benutzer.id); await expect(versionVeroeffentlichen(version.id, null, benutzer.id)).rejects.toThrow(/ohne Abschnitte/); }); it("verändert bestehende Läufe nicht", async () => { // Das ist der Grund für die ganze Versionierung. const benutzer = await benutzerAnlegen(); const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id); const kunde = await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); const lauf = await testPrisma.lauf.create({ data: { vorlagenVersionId: versionId, titel: "PC-001 neu aufsetzen", kundeId: kunde.id, erstelltVonId: benutzer.id, }, }); // Zweite Version mit ganz anderem Inhalt veroeffentlichen. const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); await entwurfSpeichern(entwurf.id, { variablen: [], abschnitte: [{ titel: "Alles neu", schritte: [{ titel: "Ein einziger Schritt" }] }], }); await versionVeroeffentlichen(entwurf.id, "Umgebaut", benutzer.id); const frisch = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id }, include: { vorlagenVersion: { include: { abschnitte: { orderBy: { position: "asc" } } }, }, }, }); expect(frisch.vorlagenVersionId).toBe(versionId); expect(frisch.vorlagenVersion.version).toBe(1); expect(frisch.vorlagenVersion.abschnitte.map((a) => a.titel)).toEqual(["Vorbereitung", "Installation"]); }); }); describe("Höchstens ein Entwurf", () => { it("lehnt einen zweiten Entwurf auch an der Datenbank ab", async () => { // Die Regel steht zusaetzlich als partieller Index in der Datenbank – der // Dienst prueft nur fuer die bessere Meldung. const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); await expect( testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 99, status: "ENTWURF" }, }), ).rejects.toThrow(/VorlagenVersion_ein_entwurf/); }); }); describe("Entwurf verwerfen", () => { it("entfernt den Entwurf samt Aufbau", async () => { const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); await entwurfVerwerfen(entwurf.id, benutzer.id); expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId: vorlage.id } })).toBe(1); // Die veroeffentlichte Version behaelt ihren Aufbau. expect(await testPrisma.vorlagenAbschnitt.count()).toBe(2); }); it("verwirft nicht die einzige Version", async () => { const benutzer = await benutzerAnlegen(); const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id); await expect(entwurfVerwerfen(version.id, benutzer.id)).rejects.toThrow(/einzige Version/); }); }); describe("Duplizieren", () => { it("legt eine eigenständige Kopie als Entwurf an", async () => { const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); const kopie = await vorlageDuplizieren(vorlage.id, "PC Neuinstallation (Außendienst)", benutzer.id); expect(kopie.id).not.toBe(vorlage.id); expect(kopie.name).toBe("PC Neuinstallation (Außendienst)"); expect(kopie.kategorie).toBe("Client"); const versionen = await testPrisma.vorlagenVersion.findMany({ where: { vorlageId: kopie.id } }); expect(versionen).toHaveLength(1); expect(versionen[0]?.status).toBe("ENTWURF"); expect(versionen[0]?.version).toBe(1); const aufbau = await strukturLesen(versionen[0]!.id); expect(aufbau.abschnitte.map((a) => a.titel)).toEqual(["Vorbereitung", "Installation"]); expect(aufbau.variablen).toHaveLength(2); }); it("lässt eine Änderung an der Kopie das Original unberührt", async () => { const benutzer = await benutzerAnlegen(); const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id); const kopie = await vorlageDuplizieren(vorlage.id, "Kopie", benutzer.id); const kopieVersion = await testPrisma.vorlagenVersion.findFirstOrThrow({ where: { vorlageId: kopie.id }, }); await entwurfSpeichern(kopieVersion.id, { variablen: [], abschnitte: [{ titel: "X", schritte: [] }] }); const original = await strukturLesen(versionId); expect(original.abschnitte).toHaveLength(2); }); it("weist eine Vorlage ohne veröffentlichte Version ab", async () => { const benutzer = await benutzerAnlegen(); const { vorlage } = await vorlageAnlegen({ name: "Nur Entwurf" }, benutzer.id); await expect(vorlageDuplizieren(vorlage.id, "Kopie", benutzer.id)).rejects.toThrow( /keine veröffentlichte Version/, ); }); }); describe("Archivieren statt Löschen", () => { it("blendet die Vorlage aus, behält aber ihre Läufe", async () => { const benutzer = await benutzerAnlegen(); const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id); const kunde = await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); await testPrisma.lauf.create({ data: { vorlagenVersionId: versionId, titel: "Ein Lauf", kundeId: kunde.id, erstelltVonId: benutzer.id, }, }); await vorlageArchivieren(vorlage.id, true, benutzer.id); const frisch = await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } }); expect(frisch.archiviert).toBe(true); expect(await testPrisma.lauf.count()).toBe(1); expect(await testPrisma.protokoll.count({ where: { aktion: "Vorlage archiviert" } })).toBe(1); }); it("lässt die Kundenzuordnung stehen, wenn sie nicht mitgeschickt wird", async () => { // Im Modus Intern zeigt das Formular keine Kundenwahl. Wuerde ein // fehlendes Feld als „auf leer setzen“ gelten, verlöre jede Vorlage beim // Speichern ihrer Kopfdaten die Zuordnung – und nach einem Wechsel zurueck // in den Modus Dienstleistung waere sie fuer alle sichtbar. const benutzer = await benutzerAnlegen(); const kunde = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } }); const { vorlage } = await vorlageAnlegen({ name: "Probe", kundeId: kunde.id }, benutzer.id); await vorlageBearbeiten(vorlage.id, { name: "Anders" }, benutzer.id); expect((await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } })).kundeId).toBe( kunde.id, ); // Ausdrücklich `null` loest sie dagegen. await vorlageBearbeiten(vorlage.id, { name: "Anders", kundeId: null }, benutzer.id); expect((await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } })).kundeId).toBeNull(); }); it("schreibt nichts, wenn sich nichts ändert", async () => { const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); const vorher = await testPrisma.protokoll.count(); await vorlageArchivieren(vorlage.id, false, benutzer.id); expect(await testPrisma.protokoll.count()).toBe(vorher); }); }); describe("Läufe hängen nur an veröffentlichten Versionen", () => { it("lässt eine veröffentlichte Version zu", async () => { const benutzer = await benutzerAnlegen(); const { versionId } = await veroeffentlichteVorlage(benutzer.id); await expect(veroeffentlichteVersionPruefen(versionId)).resolves.toBeUndefined(); }); it("weist einen Entwurf ab", async () => { const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); await expect(veroeffentlichteVersionPruefen(entwurf.id)).rejects.toThrow(/noch ein Entwurf/); }); it("weist eine archivierte Vorlage ab", async () => { const benutzer = await benutzerAnlegen(); const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id); await vorlageArchivieren(vorlage.id, true, benutzer.id); await expect(veroeffentlichteVersionPruefen(versionId)).rejects.toThrow(/startet keine Läufe/); }); }); describe("Gleichzeitigkeit", () => { it("gibt bei zwei gleichzeitigen Bearbeitungen denselben Entwurf zurück", async () => { // Ohne Wiederholung lief der zweite Aufruf in den eindeutigen Index und der // Benutzer sah „Der Entwurf konnte nicht geöffnet werden“ – obwohl er in // derselben Sekunde angelegt wurde. const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); const ergebnisse = await Promise.allSettled([ entwurfHolenOderErzeugen(vorlage.id, benutzer.id), entwurfHolenOderErzeugen(vorlage.id, benutzer.id), ]); const erfuellt = ergebnisse.filter((e) => e.status === "fulfilled"); expect(erfuellt).toHaveLength(2); const ids = new Set(erfuellt.map((e) => e.value.id)); expect(ids.size).toBe(1); expect( await testPrisma.vorlagenVersion.count({ where: { vorlageId: vorlage.id, status: "ENTWURF" }, }), ).toBe(1); }); it("lässt Speichern nicht in eine gerade veröffentlichte Version schreiben", async () => { // Der wichtigste Test dieses Meilensteins: ohne Zeilensperre meldeten beide // Aufrufe Erfolg, und die veröffentlichte Version trug hinterher Inhalt, // der nach dem Veröffentlichen geschrieben wurde. const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); const [veroeffentlichung, speichern] = await Promise.allSettled([ versionVeroeffentlichen(entwurf.id, "Veröffentlicht", benutzer.id), entwurfSpeichern(entwurf.id, { variablen: [], abschnitte: [{ titel: "NACHTRAEGLICH", schritte: [] }], }), ]); const danach = await strukturLesen(entwurf.id); /* * Welcher der beiden zuerst an die Zeile kommt, entscheidet die Datenbank — * **beide Ausgaenge** werden hier geprueft. Vorher stand die Erwartung in * einem `if`: gewann das Speichern das Rennen, lief der Test durch, ohne * etwas zu belegen, und die eigentliche Zusage waere unbemerkt gefallen * (Reviewbefund zu #016). */ expect(danach.status === "VEROEFFENTLICHT" || danach.status === "ENTWURF").toBe(true); if (danach.status === "VEROEFFENTLICHT") { expect(veroeffentlichung.status).toBe("fulfilled"); // Veroeffentlicht heisst unveraenderlich: der nachtraegliche Aufbau darf // nicht drinstehen, und das Speichern muss abgewiesen worden sein. expect(danach.abschnitte.map((a) => a.titel)).not.toContain("NACHTRAEGLICH"); expect(speichern.status).toBe("rejected"); } else { // Das Speichern war zuerst da. Dann darf nicht veroeffentlicht worden sein // – sonst trueege die veroeffentlichte Version genau diesen Abschnitt. expect(speichern.status).toBe("fulfilled"); expect(veroeffentlichung.status).toBe("rejected"); expect( await testPrisma.protokoll.count({ where: { entitaetId: entwurf.id, aktion: "Version veröffentlicht" }, }), ).toBe(0); } }); it("veröffentlicht bei zwei gleichzeitigen Aufrufen nur einmal", async () => { const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); await entwurfSpeichern(entwurf.id, STRUKTUR); const ergebnisse = await Promise.allSettled([ versionVeroeffentlichen(entwurf.id, "A", benutzer.id), versionVeroeffentlichen(entwurf.id, "B", benutzer.id), ]); expect(ergebnisse.filter((e) => e.status === "fulfilled")).toHaveLength(1); expect( await testPrisma.protokoll.count({ where: { entitaetId: entwurf.id, aktion: "Version veröffentlicht" }, }), ).toBe(1); }); }); describe("Zwei Redakteure am selben Entwurf", () => { it("weist ein Speichern auf einem überholten Stand ab", async () => { // Ohne diese Prüfung überschriebe der zweite die Arbeit des ersten // spurlos – und weil Entwürfe bewusst nicht protokolliert werden, wäre sie // auch nicht wiederherstellbar. const benutzer = await benutzerAnlegen(); const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id); const geladen = await testPrisma.vorlagenVersion.findUniqueOrThrow({ where: { id: version.id }, }); // Der erste speichert. const { stand } = await entwurfSpeichern(version.id, STRUKTUR, geladen.geaendertAm); // Der zweite hat noch den alten Stand in der Hand. await expect( entwurfSpeichern( version.id, { variablen: [], abschnitte: [{ titel: "Meine Fassung", schritte: [] }] }, geladen.geaendertAm, ), ).rejects.toThrow(/zwischenzeitlich/); // Mit dem frischen Stand geht es. await expect( entwurfSpeichern( version.id, { variablen: [], abschnitte: [{ titel: "Meine Fassung", schritte: [] }] }, stand, ), ).resolves.toBeTruthy(); }); it("speichert ohne mitgegebenen Stand weiterhin", async () => { // Ein Aufrufer, der den Stand nicht kennt (Import, Seed), soll nicht // blockiert sein. const benutzer = await benutzerAnlegen(); const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id); await expect(entwurfSpeichern(version.id, STRUKTUR)).resolves.toBeTruthy(); }); }); describe("Die Datenbank schützt die Unveränderlichkeit mit", () => { it("lässt einen Schritt einer veröffentlichten Version nicht ändern", async () => { // Bisher hing die Regel allein am Dienst. Migrationen, psql und ab Phase 4 // die Connectoren schreiben daran vorbei. const benutzer = await benutzerAnlegen(); const { versionId } = await veroeffentlichteVorlage(benutzer.id); const gelesen = await strukturLesen(versionId); const schritt = gelesen.abschnitte[0]?.schritte[0]; await expect( testPrisma.vorlagenSchritt.update({ where: { id: schritt!.id }, data: { titel: "Heimlich geändert" }, }), ).rejects.toThrow(/unveraenderlich/i); }); it("lässt Abschnitte einer veröffentlichten Version nicht löschen", async () => { const benutzer = await benutzerAnlegen(); const { versionId } = await veroeffentlichteVorlage(benutzer.id); await expect(testPrisma.vorlagenAbschnitt.deleteMany({ where: { versionId } })).rejects.toThrow( /unveraenderlich/i, ); }); it("lässt keine Variable an eine veröffentlichte Version hängen", async () => { const benutzer = await benutzerAnlegen(); const { versionId } = await veroeffentlichteVorlage(benutzer.id); await expect( testPrisma.vorlagenVariable.create({ data: { versionId, schluessel: "heimlich", beschriftung: "Heimlich" }, }), ).rejects.toThrow(/unveraenderlich/i); }); it("lässt eine veröffentlichte Version nicht zum Entwurf zurückdrehen", async () => { // Das wäre die Hintertür, durch die sich alles Obige aushebeln ließe. const benutzer = await benutzerAnlegen(); const { versionId } = await veroeffentlichteVorlage(benutzer.id); await expect( testPrisma.vorlagenVersion.update({ where: { id: versionId }, data: { status: "ENTWURF" }, }), ).rejects.toThrow(/nicht wieder zum Entwurf/i); }); it("lässt einen Entwurf weiterhin frei bearbeiten", async () => { const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); const gelesen = await strukturLesen(entwurf.id); await expect( testPrisma.vorlagenAbschnitt.update({ where: { id: gelesen.abschnitte[0]!.id }, data: { titel: "Umbenannt" }, }), ).resolves.toBeTruthy(); }); }); describe("Kopfdaten bearbeiten", () => { it("ändert Name, Kategorie und Schlagworte und protokolliert es", async () => { const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); await vorlageBearbeiten( vorlage.id, { name: "PC Neuinstallation (Standard)", kategorie: "Client", schlagworte: ["windows"] }, benutzer.id, ); const frisch = await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } }); expect(frisch.name).toBe("PC Neuinstallation (Standard)"); expect(frisch.schlagworte).toEqual(["windows"]); const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { entitaetId: vorlage.id, aktion: { contains: "geändert" } }, }); expect(eintrag.aktion).toContain("Name"); }); it("lässt die Versionen unberührt", async () => { const benutzer = await benutzerAnlegen(); const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id); await vorlageBearbeiten(vorlage.id, { name: "Anders" }, benutzer.id); const gelesen = await strukturLesen(versionId); expect(gelesen.abschnitte).toHaveLength(2); expect(gelesen.status).toBe("VEROEFFENTLICHT"); }); it("schreibt nichts, wenn sich nichts ändert", async () => { const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); const vorher = await testPrisma.protokoll.count(); await vorlageBearbeiten(vorlage.id, { name: "PC Neuinstallation" }, benutzer.id); expect(await testPrisma.protokoll.count()).toBe(vorher); }); }); describe("Archivierte Vorlage", () => { it("lässt sich nicht veröffentlichen", async () => { const benutzer = await benutzerAnlegen(); const { vorlage } = await veroeffentlichteVorlage(benutzer.id); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id); await vorlageArchivieren(vorlage.id, true, benutzer.id); await expect(versionVeroeffentlichen(entwurf.id, null, benutzer.id)).rejects.toThrow(/archiviert/); }); }); });