feat(bausteine): Abschnitte, die mehrere Vorlagen teilen (#57)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #57.
This commit is contained in:
2026-10-04 17:07:12 +00:00
parent b688e2ebc8
commit 80870133b8
24 changed files with 2862 additions and 46 deletions
+552
View File
@@ -0,0 +1,552 @@
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<readonly string[]> {
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("ü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);
});
});