import { beforeEach, describe, expect, it } from "vitest"; import { VersionStatus } from "@/generated/prisma/enums"; import { BausteinFehler, bausteinAnlegen, bausteinArchivieren, bausteinBearbeiten, bausteinEntwurfHolenOderErzeugen, bausteinEntwurfSpeichern, bausteinEntwurfVerwerfen, bausteinVersionVeroeffentlichen, } from "@/server/services/bausteine"; import { bausteinHolen, bausteinListe, bausteinVerwendungen, einsetzbareBausteine, } from "@/server/queries/bausteine"; import { VorlagenFehler, entwurfHolenOderErzeugen, entwurfSpeichern, versionVeroeffentlichen, vorlageAnlegen, } from "@/server/services/vorlagen"; import { vorlageExportieren, vorlageImportieren } from "@/server/services/vorlagen-austausch"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Wiederverwendbare Bausteine (#030). * * Die eine Zusage, die alles traegt: **eine Baustein-Aenderung fasst nichts * Veroeffentlichtes an.** Ein Entwurf verweist, eine veroeffentlichte Version * traegt Kopien — und ein Lauf sieht deshalb weiterhin konkrete Schritte. */ async function grundbestand() { const admin = await testPrisma.benutzer.create({ data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" }, }); return { adminId: admin.id }; } /** Ein veroeffentlichter Baustein mit den uebergebenen Schritttiteln. */ async function bausteinMitSchritten( name: string, titel: string, schritte: readonly string[], adminId: string, ): Promise<{ bausteinId: string; versionId: string }> { const { bausteinId, versionId } = await bausteinAnlegen({ name }, adminId); await bausteinEntwurfSpeichern(versionId, { titel, schritte: schritte.map((schritt) => ({ titel: schritt })), }); await bausteinVersionVeroeffentlichen(versionId, null, adminId); return { bausteinId, versionId }; } /** Eine Vorlage mit einem Baustein-Abschnitt und einem eigenen, veroeffentlicht. */ async function vorlageMitBaustein( name: string, bausteinVersionId: string, adminId: string, ): Promise<{ vorlageId: string; versionId: string }> { const { vorlage } = await vorlageAnlegen({ name }, adminId); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, adminId); const versionId = entwurf.id; await entwurfSpeichern(versionId, { variablen: [], abschnitte: [ { titel: "Eigener Abschnitt", schritte: [{ titel: "Eigener Schritt" }] }, { titel: "wird ersetzt", bausteinVersionId, schritte: [] }, ], }); await versionVeroeffentlichen(versionId, null, adminId); return { vorlageId: vorlage.id, versionId }; } async function schritteVon(versionId: string): Promise { const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({ where: { versionId }, orderBy: { position: "asc" }, include: { schritte: { orderBy: { position: "asc" } } }, }); return abschnitte.flatMap((abschnitt) => [ `# ${abschnitt.titel}`, ...abschnitt.schritte.map((schritt) => schritt.titel), ]); } describe.runIf(datenbankVerfuegbar)("Bausteine", () => { beforeEach(async () => { await datenbankLeeren(); }); describe("Pflegen", () => { it("legt einen Baustein mit Entwurf in Version 1 an", async () => { const { adminId } = await grundbestand(); const { bausteinId, versionId } = await bausteinAnlegen({ name: "Datensicherung prüfen" }, adminId); const ansicht = await bausteinHolen(bausteinId); expect(ansicht?.name).toBe("Datensicherung prüfen"); expect(ansicht?.versionen).toHaveLength(1); expect(ansicht?.versionen[0]).toMatchObject({ id: versionId, version: 1, status: VersionStatus.ENTWURF, // Der Titel beginnt als Name – in den meisten Fällen stimmt das. titel: "Datensicherung prüfen", }); }); it("hält Namen eindeutig, mit einem Satz statt eines Datenbankfehlers", async () => { const { adminId } = await grundbestand(); await bausteinAnlegen({ name: "Datensicherung prüfen" }, adminId); await expect(bausteinAnlegen({ name: "Datensicherung prüfen" }, adminId)).rejects.toThrow( /gibt schon einen Baustein/, ); }); it("lässt je Baustein höchstens einen Entwurf zu – auch an der Service-Schicht vorbei", async () => { const { adminId } = await grundbestand(); const { bausteinId } = await bausteinAnlegen({ name: "Einmal" }, adminId); await expect( testPrisma.bausteinVersion.create({ data: { bausteinId, version: 2, status: VersionStatus.ENTWURF, titel: "zweiter" }, }), ).rejects.toThrow(/BausteinVersion_ein_entwurf/); }); it("weist einen Baustein ohne Schritte beim Veröffentlichen ab", async () => { const { adminId } = await grundbestand(); const { versionId } = await bausteinAnlegen({ name: "Leer" }, adminId); await expect(bausteinVersionVeroeffentlichen(versionId, null, adminId)).rejects.toThrow( /ohne Schritte/, ); }); it("macht aus einer veröffentlichten Version einen Entwurf als tiefe Kopie", async () => { const { adminId } = await grundbestand(); const { bausteinId } = await bausteinMitSchritten( "Datensicherung prüfen", "Datensicherung", ["Sicherung läuft", "Rücksicherung getestet"], adminId, ); const entwurfId = await bausteinEntwurfHolenOderErzeugen(bausteinId, adminId); const ansicht = await bausteinHolen(bausteinId); expect(ansicht?.versionen).toHaveLength(2); const entwurf = ansicht?.versionen.find((version) => version.id === entwurfId); expect(entwurf?.version).toBe(2); expect(entwurf?.schritte.map((schritt) => schritt.titel)).toEqual([ "Sicherung läuft", "Rücksicherung getestet", ]); // Zweiter Aufruf gibt denselben Entwurf, nicht einen dritten. expect(await bausteinEntwurfHolenOderErzeugen(bausteinId, adminId)).toBe(entwurfId); }); it("lässt eine veröffentlichte Version nicht ändern – der Trigger hält das fest", async () => { const { adminId } = await grundbestand(); const { versionId } = await bausteinMitSchritten("Fest", "Fest", ["Eins"], adminId); await expect( bausteinEntwurfSpeichern(versionId, { titel: "anders", schritte: [{ titel: "Zwei" }] }), ).rejects.toThrow(/unveränderlich/); // Und auch an der Service-Schicht vorbei nicht. await expect( testPrisma.bausteinSchritt.create({ data: { versionId, titel: "Geschmuggelt" } }), ).rejects.toThrow(/unveraenderlich/); }); it("lässt eine veröffentlichte Version nicht zum Entwurf zurückmachen", async () => { const { adminId } = await grundbestand(); const { versionId } = await bausteinMitSchritten("Einbahn", "Einbahn", ["Eins"], adminId); await expect( testPrisma.bausteinVersion.update({ where: { id: versionId }, data: { status: VersionStatus.ENTWURF }, }), ).rejects.toThrow(/nicht wieder zum Entwurf/); }); it("verwirft einen Entwurf nur, wenn es noch eine veröffentlichte Version gibt", async () => { const { adminId } = await grundbestand(); const { bausteinId, versionId } = await bausteinAnlegen({ name: "Einzig" }, adminId); await expect(bausteinEntwurfVerwerfen(versionId, adminId)).rejects.toThrow(/einzige Version/); await bausteinEntwurfSpeichern(versionId, { titel: "Einzig", schritte: [{ titel: "Eins" }] }); await bausteinVersionVeroeffentlichen(versionId, null, adminId); const zweiter = await bausteinEntwurfHolenOderErzeugen(bausteinId, adminId); await bausteinEntwurfVerwerfen(zweiter, adminId); expect((await bausteinHolen(bausteinId))?.versionen).toHaveLength(1); }); it("benennt um und protokolliert", async () => { const { adminId } = await grundbestand(); const { bausteinId } = await bausteinAnlegen({ name: "Alt" }, adminId); await bausteinBearbeiten(bausteinId, { name: "Neu", beschreibung: "Erklärung" }, adminId); expect((await bausteinHolen(bausteinId))?.name).toBe("Neu"); const eintraege = await testPrisma.protokoll.findMany({ where: { entitaet: "Baustein", entitaetId: bausteinId }, orderBy: { erstelltAm: "asc" }, select: { aktion: true }, }); expect(eintraege.map((eintrag) => eintrag.aktion)).toEqual([ "Baustein angelegt", "Name und Beschreibung geändert", ]); }); }); describe("Einsetzen und Auflösen", () => { it("setzt die Schritte des Bausteins beim Veröffentlichen ein", async () => { const { adminId } = await grundbestand(); const { versionId: bausteinVersionId } = await bausteinMitSchritten( "Datensicherung prüfen", "Datensicherung", ["Sicherung läuft", "Rücksicherung getestet"], adminId, ); const { versionId } = await vorlageMitBaustein("PC Neuinstallation", bausteinVersionId, adminId); expect(await schritteVon(versionId)).toEqual([ "# Eigener Abschnitt", "Eigener Schritt", // Titel und Schritte kommen aus dem Baustein, nicht aus dem Entwurf. "# Datensicherung", "Sicherung läuft", "Rücksicherung getestet", ]); }); it("lässt den Verweis als Herkunft stehen", async () => { const { adminId } = await grundbestand(); const { versionId: bausteinVersionId } = await bausteinMitSchritten( "Herkunft", "Herkunft", ["Eins"], adminId, ); const { versionId } = await vorlageMitBaustein("Mit Herkunft", bausteinVersionId, adminId); const abschnitt = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({ where: { versionId, bausteinVersionId: { not: null } }, include: { bausteinVersion: { include: { baustein: true } } }, }); expect(abschnitt.bausteinVersion?.baustein.name).toBe("Herkunft"); }); it("weist einen Verweis auf einen Entwurf ab", async () => { // Einen Entwurf einzusetzen hieße, beim Veröffentlichen etwas zu kopieren, // das sich danach noch ändert. const { adminId } = await grundbestand(); const { versionId: entwurfId } = await bausteinAnlegen({ name: "Noch Entwurf" }, adminId); const { vorlage } = await vorlageAnlegen({ name: "Will einen Entwurf" }, adminId); const versionId = (await entwurfHolenOderErzeugen(vorlage.id, adminId)).id; await expect( entwurfSpeichern(versionId, { variablen: [], abschnitte: [{ titel: "x", bausteinVersionId: entwurfId, schritte: [] }], }), ).rejects.toThrow(/veroeffentlichte Baustein-Version/); }); it("weist das Veröffentlichen ab, wenn der Baustein archiviert wurde", async () => { const { adminId } = await grundbestand(); const { bausteinId, versionId: bausteinVersionId } = await bausteinMitSchritten( "Weggelegt", "Weggelegt", ["Eins"], adminId, ); const { vorlage } = await vorlageAnlegen({ name: "Mit weggelegtem" }, adminId); const versionId = (await entwurfHolenOderErzeugen(vorlage.id, adminId)).id; await entwurfSpeichern(versionId, { variablen: [], abschnitte: [{ titel: "x", bausteinVersionId, schritte: [] }], }); // Archivieren geht hier nicht: der Baustein steht in einem Entwurf. await expect(bausteinArchivieren(bausteinId, true, adminId)).rejects.toThrow(/Entwurf/); // An der Service-Schicht vorbei archiviert – dann greift die Prüfung beim // Veröffentlichen. await testPrisma.baustein.update({ where: { id: bausteinId }, data: { archiviert: true } }); await expect(versionVeroeffentlichen(versionId, null, adminId)).rejects.toThrow(/archiviert/); }); it("hält im Protokoll fest, welcher Baustein in welcher Fassung eingesetzt wurde", async () => { const { adminId } = await grundbestand(); const { versionId: bausteinVersionId } = await bausteinMitSchritten( "Datensicherung prüfen", "Datensicherung", ["Eins"], adminId, ); const { versionId } = await vorlageMitBaustein("Mit Protokoll", bausteinVersionId, adminId); const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { entitaet: "VorlagenVersion", entitaetId: versionId, aktion: "Version veröffentlicht" }, }); expect(eintrag.nachher).toMatchObject({ bausteine: [{ name: "Datensicherung prüfen", version: 1 }], }); }); }); describe("Unveränderlichkeit über Baustein-Änderungen hinweg", () => { it("ändert keine veröffentlichte Vorlagenversion, wenn der Baustein neu veröffentlicht wird", async () => { const { adminId } = await grundbestand(); const { bausteinId, versionId: ersteBausteinVersion } = await bausteinMitSchritten( "Datensicherung prüfen", "Datensicherung", ["Sicherung läuft"], adminId, ); const { versionId } = await vorlageMitBaustein("PC Neuinstallation", ersteBausteinVersion, adminId); const vorher = await schritteVon(versionId); // Zweite Fassung des Bausteins, inhaltlich ganz anders. const zweiterEntwurf = await bausteinEntwurfHolenOderErzeugen(bausteinId, adminId); await bausteinEntwurfSpeichern(zweiterEntwurf, { titel: "Datensicherung, neu gedacht", schritte: [{ titel: "Ganz anders" }, { titel: "Und noch einer" }], }); await bausteinVersionVeroeffentlichen(zweiterEntwurf, "Umgebaut", adminId); expect(await schritteVon(versionId)).toEqual(vorher); }); it("lässt einen laufenden Lauf unberührt", async () => { const { adminId } = await grundbestand(); const kunde = await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); const { bausteinId, versionId: ersteBausteinVersion } = await bausteinMitSchritten( "Datensicherung prüfen", "Datensicherung", ["Sicherung läuft"], adminId, ); const { versionId } = await vorlageMitBaustein("PC Neuinstallation", ersteBausteinVersion, adminId); const lauf = await testPrisma.lauf.create({ data: { vorlagenVersionId: versionId, titel: "Ein Lauf", kundeId: kunde.id }, }); const zweiterEntwurf = await bausteinEntwurfHolenOderErzeugen(bausteinId, adminId); await bausteinEntwurfSpeichern(zweiterEntwurf, { titel: "Anders", schritte: [{ titel: "Ganz anders" }], }); await bausteinVersionVeroeffentlichen(zweiterEntwurf, null, adminId); const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id }, include: { vorlagenVersion: { include: { abschnitte: { include: { schritte: true }, orderBy: { position: "asc" } } }, }, }, }); const titel = nachher.vorlagenVersion.abschnitte.flatMap((abschnitt) => abschnitt.schritte.map((schritt) => schritt.titel), ); expect(titel).toContain("Sicherung läuft"); expect(titel).not.toContain("Ganz anders"); }); it("nimmt die neue Fassung in die nächste veröffentlichte Vorlagenversion", async () => { const { adminId } = await grundbestand(); const { bausteinId, versionId: ersteBausteinVersion } = await bausteinMitSchritten( "Datensicherung prüfen", "Datensicherung", ["Sicherung läuft"], adminId, ); const { vorlageId } = await vorlageMitBaustein("PC Neuinstallation", ersteBausteinVersion, adminId); const zweiterEntwurf = await bausteinEntwurfHolenOderErzeugen(bausteinId, adminId); await bausteinEntwurfSpeichern(zweiterEntwurf, { titel: "Datensicherung", schritte: [{ titel: "Sicherung läuft" }, { titel: "Rücksicherung getestet" }], }); await bausteinVersionVeroeffentlichen(zweiterEntwurf, null, adminId); const neueFassung = await testPrisma.bausteinVersion.findFirstOrThrow({ where: { bausteinId, status: VersionStatus.VEROEFFENTLICHT }, orderBy: { version: "desc" }, }); // Neue Vorlagenversion, die auf die neue Fassung zeigt. const zweiteVorlagenVersion = (await entwurfHolenOderErzeugen(vorlageId, adminId)).id; await entwurfSpeichern(zweiteVorlagenVersion, { variablen: [], abschnitte: [ { titel: "Eigener Abschnitt", schritte: [{ titel: "Eigener Schritt" }] }, { titel: "x", bausteinVersionId: neueFassung.id, schritte: [] }, ], }); await versionVeroeffentlichen(zweiteVorlagenVersion, null, adminId); expect(await schritteVon(zweiteVorlagenVersion)).toEqual([ "# Eigener Abschnitt", "Eigener Schritt", "# Datensicherung", "Sicherung läuft", "Rücksicherung getestet", ]); }); }); describe("Löschen und Verwendungen", () => { it("lässt eine Baustein-Version nicht löschen, auf die eine Vorlage zeigt", async () => { const { adminId } = await grundbestand(); const { versionId: bausteinVersionId } = await bausteinMitSchritten( "Benutzt", "Benutzt", ["Eins"], adminId, ); await vorlageMitBaustein("Benutzt ihn", bausteinVersionId, adminId); await expect(testPrisma.bausteinVersion.delete({ where: { id: bausteinVersionId } })).rejects.toThrow(); }); it("nennt, wo ein Baustein steckt – mit Vorlage, Version und Fassung", async () => { const { adminId } = await grundbestand(); const { bausteinId, versionId: bausteinVersionId } = await bausteinMitSchritten( "Überall", "Überall", ["Eins"], adminId, ); await vorlageMitBaustein("Vorlage A", bausteinVersionId, adminId); await vorlageMitBaustein("Vorlage B", bausteinVersionId, adminId); const verwendungen = await bausteinVerwendungen(bausteinId); expect(verwendungen).toHaveLength(2); expect(verwendungen.map((eintrag) => eintrag.vorlageName)).toEqual(["Vorlage A", "Vorlage B"]); expect(verwendungen[0]).toMatchObject({ vorlagenVersion: 1, status: VersionStatus.VEROEFFENTLICHT, bausteinVersion: 1, }); }); it("zählt in der Liste Verwendungen und Schritte", async () => { const { adminId } = await grundbestand(); const { versionId: bausteinVersionId } = await bausteinMitSchritten( "Gezählt", "Gezählt", ["Eins", "Zwei"], adminId, ); await vorlageMitBaustein("Benutzt ihn", bausteinVersionId, adminId); const liste = await bausteinListe(); expect(liste).toHaveLength(1); expect(liste[0]).toMatchObject({ name: "Gezählt", veroeffentlichteVersion: 1, hatEntwurf: false, anzahlSchritte: 2, verwendungen: 1, }); }); it("bietet zum Einsetzen nur veröffentlichte Fassungen nicht archivierter Bausteine", async () => { const { adminId } = await grundbestand(); await bausteinMitSchritten("Brauchbar", "Brauchbar", ["Eins"], adminId); await bausteinAnlegen({ name: "Nur Entwurf" }, adminId); const { bausteinId } = await bausteinMitSchritten("Weggelegt", "Weggelegt", ["Eins"], adminId); await bausteinArchivieren(bausteinId, true, adminId); const einsetzbar = await einsetzbareBausteine(); expect(einsetzbar.map((eintrag) => eintrag.name)).toEqual(["Brauchbar"]); expect(einsetzbar[0]).toMatchObject({ version: 1, anzahlSchritte: 1, titel: "Brauchbar" }); }); }); describe("Austausch", () => { it("exportiert die aufgelösten Schritte und keinen Verweis", async () => { // Eine Kennung aus dieser Installation bedeutet in einer anderen nichts. const { adminId } = await grundbestand(); const { versionId: bausteinVersionId } = await bausteinMitSchritten( "Datensicherung prüfen", "Datensicherung", ["Sicherung läuft"], adminId, ); const { versionId } = await vorlageMitBaustein("Zum Ausfuhren", bausteinVersionId, adminId); const austausch = await vorlageExportieren(versionId); expect(JSON.stringify(austausch)).not.toContain(bausteinVersionId); const abschnitte = austausch.struktur.abschnitte.map((abschnitt) => ({ titel: abschnitt.titel, schritte: abschnitt.schritte.map((schritt) => schritt.titel), })); expect(abschnitte).toEqual([ { titel: "Eigener Abschnitt", schritte: ["Eigener Schritt"] }, { titel: "Datensicherung", schritte: ["Sicherung läuft"] }, ]); }); it("exportiert auch einen Entwurf mit seinen Baustein-Schritten", async () => { /* * **Der Fall, der still einen leeren Abschnitt lieferte** (Reviewbefund zu * #029). Ein Baustein-Abschnitt im Entwurf hat keine eigenen Schritte — * die entstehen erst beim Veröffentlichen. Der Export schrieb deshalb * `{"titel":"wird ersetzt","schritte":[]}`, und der Import nahm das * stillschweigend an: das Schema lässt einen leeren Abschnitt zu. * * Ein Redakteur bringt eine noch unveröffentlichte Vorlage in eine zweite * Installation — genau der Zweck des Formats. Dort kam der geteilte * Abschnitt unter dem Editor-Platzhalter „wird ersetzt“ und ohne einen * einzigen Schritt an. Die Checkliste war unvollständig, und nichts sagte * es. */ const { adminId } = await grundbestand(); const { versionId: bausteinVersionId } = await bausteinMitSchritten( "Datensicherung prüfen", "Datensicherung", ["Sicherung läuft", "Rücksicherung geprüft"], adminId, ); // Wie `vorlageMitBaustein`, aber **ohne** zu veröffentlichen. const { vorlage } = await vorlageAnlegen({ name: "Noch Entwurf" }, adminId); const entwurf = await entwurfHolenOderErzeugen(vorlage.id, adminId); await entwurfSpeichern(entwurf.id, { variablen: [], abschnitte: [ { titel: "Eigener Abschnitt", schritte: [{ titel: "Eigener Schritt" }] }, { titel: "wird ersetzt", bausteinVersionId, schritte: [] }, ], }); const austausch = await vorlageExportieren(entwurf.id); expect( austausch.struktur.abschnitte.map((abschnitt) => ({ titel: abschnitt.titel, schritte: abschnitt.schritte.map((schritt) => schritt.titel), })), ).toEqual([ { titel: "Eigener Abschnitt", schritte: ["Eigener Schritt"] }, // Der Titel aus dem Baustein, nicht der Platzhalter – und die Schritte. { titel: "Datensicherung", schritte: ["Sicherung läuft", "Rücksicherung geprüft"] }, ]); // Und weiterhin kein Verweis: in der anderen Installation gibt es den // Baustein vielleicht nicht. expect(JSON.stringify(austausch)).not.toContain(bausteinVersionId); // Kein Abschnitt ohne Schritte – das ist die Zusage, die vorher brach. for (const abschnitt of austausch.struktur.abschnitte) { expect(abschnitt.schritte.length, abschnitt.titel).toBeGreaterThan(0); } }); it("übernimmt beim Import keinen mitgeschickten Verweis", async () => { const { adminId } = await grundbestand(); const { versionId: bausteinVersionId } = await bausteinMitSchritten( "Datensicherung prüfen", "Datensicherung", ["Sicherung läuft"], adminId, ); const { versionId } = await vorlageMitBaustein("Quelle", bausteinVersionId, adminId); const austausch = await vorlageExportieren(versionId); const { versionId: kopie } = await vorlageImportieren( { ...austausch, name: "Kopie", struktur: { ...austausch.struktur, abschnitte: austausch.struktur.abschnitte.map((abschnitt) => ({ ...abschnitt, bausteinVersionId, })), }, }, adminId, ); const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({ where: { versionId: kopie } }); expect(abschnitte.every((abschnitt) => abschnitt.bausteinVersionId === null)).toBe(true); // Die Schritte sind trotzdem alle da – sie stehen in der Datei. expect(await schritteVon(kopie)).toEqual([ "# Eigener Abschnitt", "Eigener Schritt", "# Datensicherung", "Sicherung läuft", ]); }); }); it("wirft eigene Fehlerklassen, damit die Actions sie unterscheiden können", async () => { const { adminId } = await grundbestand(); await expect(bausteinAnlegen({ name: " " }, adminId)).rejects.toThrow(BausteinFehler); await expect(versionVeroeffentlichen("gibt-es-nicht", null, adminId)).rejects.toThrow(VorlagenFehler); }); });