Files
Laufzettel/tests/bausteine.test.ts
T
florian.wilde 80870133b8
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(bausteine): Abschnitte, die mehrere Vorlagen teilen (#57)
2026-10-04 17:07:12 +00:00

553 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});