Files
Laufzettel/tests/vorlagen.test.ts
T
Florian WildeandClaude Opus 5 548c090562
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): Editor, Liste und Versionsgeschichte
Der Redakteur baut eine Vorlage jetzt vollstaendig in der Oberflaeche: Liste mit
Suche und Filtern, Editor mit Abschnitten, Schritten und Variablen, Vorschau mit
Beispielwerten, Veroeffentlichen mit Aenderungsnotiz, Versionsgeschichte.

Sortiert wird mit dem Finger, der Maus, der Tastatur **und** zwei Knoepfen an
jeder Zeile — ein Touch-Drag gelingt auf einem 390-px-Geraet nicht jedem, und
die Reihenfolge ist fachlich zu wichtig fuer nur einen Weg. Schritte, Variablen
und Abschnitte klappen zu: dieselbe Vorlage misst am Handy 3 482 statt 16 986 px.

- src/lib/platzhalter.ts als einzige Quelle der eingebauten Platzhalter; der
  Editor warnt vor unbekannten, blockiert aber nichts
- Hilfetexte als Markdown ohne rohes HTML, Bilder als Alternativtext
- Erste Playwright-Tests: der Editor wird durch die Oberflaeche gefahren

Wichtigster Befund des Meilensteins: `next dev` hydriert in dieser Umgebung
ueberhaupt nicht — die Seiten sehen richtig aus, aber kein Knopf tut etwas
(2 von 1302 Elementen tragen React-Knoten, im gebauten Stand 1281 von 1296).
Nicht an diesem Code: auch ohne proxy.ts und ohne output: standalone. Ein
Screenshot beweist damit nichts ueber Bedienbarkeit. Oberflaechen werden ab
sofort gegen `pnpm build && pnpm start` abgenommen, und was nur mit angehaengtem
React gelingt, gehoert in einen Playwright-Test.

Der unabhaengige Review fand zwei Blocker: eine abgelaufene Anmeldung haette im
Editor jede ungespeicherte Arbeit vernichtet (der Rechtefehler lief bis in Nexts
Fehlergrenze), und das Speichern der Kopfdaten loeste eine archivierte
Kundenzuordnung stillschweigend auf. Dazu sechs weitere Befunde, alle behoben.

496 Tests gruen (vorher 428), dazu 5 E2E-Tests.

Schliesst #010

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

765 lines
31 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 die Stelle so, wie sie im Editor steht", async () => {
// „abschnitte.0.schritte.1.titel“ ist richtig und nutzlos: der Redakteur
// muesste von null an zaehlen. Der Review hat es als den Weg benannt, auf
// dem Arbeit wirklich verloren geht — mehrfach vergeblich speichern, dann
// weggehen.
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("Abschnitt 01, Schritt 2: Ein Schritt braucht einen 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("lässt die Kundenzuordnung stehen, wenn sie nicht mitgeschickt wird", async () => {
// Im Modus Intern zeigt das Formular keine Kundenwahl. Wuerde ein
// fehlendes Feld als „auf leer setzen“ gelten, verlöre jede Vorlage beim
// Speichern ihrer Kopfdaten die Zuordnung – und nach einem Wechsel zurueck
// in den Modus Dienstleistung waere sie fuer alle sichtbar.
const benutzer = await benutzerAnlegen();
const kunde = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const { vorlage } = await vorlageAnlegen({ name: "Probe", kundeId: kunde.id }, benutzer.id);
await vorlageBearbeiten(vorlage.id, { name: "Anders" }, benutzer.id);
expect((await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } })).kundeId).toBe(
kunde.id,
);
// Ausdrücklich `null` loest sie dagegen.
await vorlageBearbeiten(vorlage.id, { name: "Anders", kundeId: null }, benutzer.id);
expect((await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } })).kundeId).toBeNull();
});
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/);
});
});
});