Files
Laufzettel/tests/vorlagen.test.ts
T
Florian WildeandClaude Opus 5 abacad1cb7
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch
Eine veroeffentlichte Vorlagen-Version ist fuer immer unveraenderlich — daran
haengt, dass ein archivierter Lauf in zehn Jahren noch genau die Schritte zeigt,
mit denen er gestartet wurde. Die Regel steht deshalb zweimal: im Service, der
die verstaendliche Meldung gibt, und in der Datenbank, die die Garantie gibt.
Vier Trigger fangen Schreibzugriffe ab, die nicht durch den Service kommen —
Migrationen, psql, der Seed und ab Phase 4 die Connectoren — und versperren den
Rueckweg nach ENTWURF, der alles andere ausgehebelt haette.

- Service: anlegen, Kopfdaten bearbeiten, Entwurf als tiefe Kopie der letzten
  veroeffentlichten Version, speichern, veroeffentlichen, verwerfen,
  duplizieren, archivieren
- Hoechstens ein Entwurf je Vorlage, durchgesetzt vom partiellen Index; zwei
  gleichzeitige Klicks auf „Bearbeiten“ bekommen denselben Entwurf statt eines
  Fehlers
- Optimistisches Sperren ueber geaendertAm: wer auf einem ueberholten Stand
  speichert, wird abgewiesen statt die Arbeit des anderen spurlos zu ersetzen
- JSON-Export und -Import samt Format-Dokumentation; der Export ist nachsichtig,
  der Import streng
- src/lib/validierung.ts als einzige Zod-Quelle mit deutschen Meldungen, per
  ESLint-Regel erzwungen

Der unabhaengige Review fand drei Blocker, alle an der Mandantengrenze: der
Suchfilter brachte ein zweites OR mit und ueberschrieb die Kundenbedingung,
Historie und Aufbau einer Version filterten gar nicht. Zu queries/vorlagen.ts
gab es keinen Test — jetzt gibt es tests/vorlagen-sicht.test.ts.

428 Tests gruen (vorher 336).

Schliesst #009

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 08:09:21 +02:00

742 lines
30 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 {
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/);
});
});
});