feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch (#23)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #23.
This commit is contained in:
2026-09-25 06:10:01 +00:00
parent ecc3c579df
commit d8d5444e9f
41 changed files with 4006 additions and 104 deletions
+741
View File
@@ -0,0 +1,741 @@
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 bei einem Fehler im Aufbau den Pfad", async () => {
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(/abschnitte\.0\.schritte\.1\.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("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);
await Promise.allSettled([
versionVeroeffentlichen(entwurf.id, "Veröffentlicht", benutzer.id),
entwurfSpeichern(entwurf.id, {
variablen: [],
abschnitte: [{ titel: "NACHTRAEGLICH", schritte: [] }],
}),
]);
const danach = await strukturLesen(entwurf.id);
if (danach.status === "VEROEFFENTLICHT") {
// Wurde veroeffentlicht, darf der nachträgliche Aufbau nicht drinstehen.
expect(danach.abschnitte.map((a) => a.titel)).not.toContain("NACHTRAEGLICH");
}
});
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/);
});
});
});