Files
Laufzettel/tests/vorlagen-austausch.test.ts
T
Florian WildeandClaude Opus 5 a2330ef682
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): bedingte Abschnitte und Schritte (#039)
Ein Abschnitt oder ein einzelner Schritt erscheint nur, wenn seine Bedingung
zutrifft — abhaengig von einer Variable des Laufs oder vom Zustand eines
anderen Schritts. Der echte Fall steht im Seed: "Mitarbeiter-Onboarding" traegt
beide Konto-Abschnitte, fuenf AD-Schritte und vier fuer Entra ID, und
`konto_typ` sagt beim Start, welcher gilt. Bisher klickte ein Techniker die
anderen neun bei jedem Onboarding weg.

Entscheidungen, alle im Issue begruendet:

- Laufend ausgewertet, nicht beim Start festgeschrieben. Fuer die
  Abteilungsabschnitte (#031) ist Festschreiben richtig; fuer Bedingungen nicht
  — die Antwort auf "ist Pruefen fehlgeschlagen?" entsteht erst waehrend der
  Arbeit. Ein struktureller Test prueft, dass alle drei Schichten
  (Abschlussregel, Abarbeitung, Druck) die Auswertung rufen.
- Nicht entscheidbar heisst: gilt. Ein Lauf ohne konto_typ zeigt beide
  Abschnitte — genau wie vorher. Ein Abschnitt zu viel ist sichtbar und
  korrigierbar, ein fehlender faellt niemandem auf.
- OFFEN ist eine Antwort, keine Luecke: waere es unentscheidbar, waere ein
  bedingter Abschnitt von Anfang an sichtbar.
- Eine Bedingung, kein Ausdrucksbaum. Kein und, kein oder.
- Verwiesen wird ueber Titel-Pfade, nicht ueber Kennungen: vier Wege schreiben
  eine Struktur neu und geben neue Kennungen.

Zwei stille Datenverluste gefunden, der zweite aelter als dieser Meilenstein:

- Der Import verwarf `nurFuerAbteilungen`. Der Export schreibt es seit #031, das
  Schema nimmt es an, der Import setzte es nicht — eine exportierte Vorlage
  verlor ihre Abteilungsabschnitte und galt dort fuer alle.
- Die tiefe Kopie verwarf es ebenfalls, womit jedes "Bearbeiten" einer Vorlage
  alle Einschraenkungen verlor. Ursache war ein von Hand getippter Zwischentyp,
  der weniger Felder nannte als die Abfrage liefert; jetzt aus Prisma
  abgeleitet, beide Kopierwege teilen eine Stelle, und ein Test vergleicht Kopie
  und Original Feld fuer Feld.

Gefunden hat beides die Klickprobe am gebauten Stand, nicht die Suite: im Editor
stand "Bedingung gesetzt", nach "Bearbeiten" war sie weg. Dreizehn Gegenproben
liefen, alle wurden rot — und der teuerste Befund lag ausserhalb von allen.

1499 Vitest-Tests (vorher 1457), 115 E2E-Ablaeufe gruen.

Closes #39

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-23 14:54:10 +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 { GRENZEN, type Austausch } from "@/lib/vorlagen-format";
import {
AustauschFehler,
vorlageExportieren,
vorlageImportieren,
} from "@/server/services/vorlagen-austausch";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* JSON-Austausch fuer Vorlagen (#009).
*
* Der Rundlauf ist der eigentliche Prueffall: was exportiert und wieder
* eingespielt wird, muss dasselbe sein. Alles andere hier haelt fest, was dabei
* bewusst *nicht* mitwandert (Kennungen, Positionen, Kundenbezug) und was ein
* Import mit einer krummen Datei tun soll.
*/
/** Ein Befehl mit Platzhaltern – er darf den Rundlauf Zeichen fuer Zeichen ueberleben. */
const BEFEHL = 'New-ADUser -Name "{{vorname}} {{nachname}}" -Department "{{abteilung}}"';
/** Urheber der Protokolleintraege; ohne echten Benutzer greift der Fremdschluessel. */
async function redakteurAnlegen() {
return testPrisma.benutzer.create({
data: { email: "redaktion@firma.example", name: "Redaktion", rolle: "REDAKTEUR" },
});
}
/**
* Eine veroeffentlichte Version mit zwei Abschnitten, Schritten verschiedener
* Typen und zwei Variablen.
*
* Die Zeilen werden absichtlich in verkehrter Reihenfolge angelegt: dann zeigt
* der Export, dass er nach `position` sortiert und nicht danach, wie die
* Datenbank die Zeilen gerade zurueckgibt.
*/
/**
* Legt die Beispielvorlage an und veröffentlicht sie.
*
* `nachtragen` wird **vor** dem Veröffentlichen gerufen: an einer
* veröffentlichten Version lässt sich nichts mehr ändern, dafür sorgen Trigger
* in der Datenbank (#009). Wer eine Abteilungseinschränkung oder eine Bedingung
* braucht, setzt sie dort — genau wie im Editor.
*/
async function beispielVersionAnlegen(nachtragen?: (versionId: string) => Promise<void>): Promise<string> {
const vorlage = await testPrisma.vorlage.create({
data: {
name: "Onboarding Notebook",
beschreibung: "Neue Person, neues Gerät.",
kategorie: "Onboarding",
schlagworte: ["notebook", "eintritt"],
},
});
// Erst als Entwurf anlegen und befuellen, dann veroeffentlichen: an einer
// veroeffentlichten Version laesst sich nichts mehr anhaengen, dafuer sorgen
// Trigger in der Datenbank.
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 3, status: "ENTWURF" },
});
await testPrisma.vorlagenVariable.create({
data: {
versionId: version.id,
schluessel: "abteilung",
beschriftung: "Abteilung",
typ: "AUSWAHL",
optionen: ["IT", "Vertrieb"],
pflicht: false,
position: 1,
},
});
await testPrisma.vorlagenVariable.create({
data: {
versionId: version.id,
schluessel: "vorname",
beschriftung: "Vorname",
typ: "TEXT",
pflicht: true,
position: 0,
},
});
const zweiter = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "02 · Konto", position: 1 },
});
const erster = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "01 · Vorbereitung", position: 0 },
});
await testPrisma.vorlagenSchritt.createMany({
data: [
{
abschnittId: erster.id,
titel: "Gerät auspacken",
typ: "HAKEN",
pflicht: true,
position: 0,
},
{
abschnittId: erster.id,
titel: "Seriennummer erfassen",
hilfetext: "Aufkleber auf der Unterseite.",
typ: "TEXT",
pflicht: true,
konfig: { mehrzeilig: false },
dokuUrl: "https://intern.example/seriennummern",
position: 1,
},
{
abschnittId: erster.id,
titel: "Arbeitsspeicher prüfen",
typ: "ZAHL",
konfig: { min: 8, max: 64, einheit: "GB" },
position: 2,
},
],
});
await testPrisma.vorlagenSchritt.createMany({
data: [
{
abschnittId: zweiter.id,
titel: "Benutzerkonto anlegen",
typ: "BEFEHL",
pflicht: true,
// Ein unbekannter Schluessel: das Schema ist nachsichtig, damit ein
// Export aus einer spaeteren Fassung hier nichts verliert.
konfig: { shell: "powershell", befehl: BEFEHL, kuenftigesFeld: { tief: [1, 2] } },
position: 0,
},
{
abschnittId: zweiter.id,
titel: "Eintrittsdatum hinterlegen",
typ: "DATUM",
position: 1,
},
],
});
await nachtragen?.(version.id);
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
return version.id;
}
/** Die Abschnitte einer Version samt Schritten, in Reihenfolge. */
async function abschnitteVon(versionId: string) {
return testPrisma.vorlagenAbschnitt.findMany({
where: { versionId },
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
});
}
/** Das kleinste Dokument, das durchgeht – Ausgangspunkt fuer die Fehlerfaelle. */
function dokument(aenderung: Record<string, unknown> = {}): Record<string, unknown> {
return {
format: 1,
name: "Kleine Vorlage",
struktur: {
variablen: [{ schluessel: "vorname", beschriftung: "Vorname" }],
abschnitte: [{ titel: "01 · Start", schritte: [{ titel: "Anfangen" }] }],
},
...aenderung,
};
}
/** Der Rundlauf darf sich in `version` unterscheiden – alles andere nicht. */
function ohneVersion(austausch: Austausch): Omit<Austausch, "version"> {
const kopie: Omit<Austausch, "version"> & { version?: number } = { ...austausch };
delete kopie.version;
return kopie;
}
/**
* Diese Faelle scheitern am Schema, also noch vor dem ersten Datenbankzugriff –
* deshalb laufen sie auch ohne Testdatenbank.
*/
describe("Import: Pruefung des Dokuments", () => {
it("weist ein fehlendes oder falsches Format ab", async () => {
const ohneFormat = dokument();
delete ohneFormat["format"];
await expect(vorlageImportieren(ohneFormat, "wer-auch-immer")).rejects.toThrow(AustauschFehler);
await expect(vorlageImportieren(dokument({ format: 2 }), "wer")).rejects.toThrow(/Format 1/);
await expect(vorlageImportieren("kein Objekt", "wer")).rejects.toThrow(AustauschFehler);
});
it("nennt bei einem leeren Schritt-Titel den Pfad", async () => {
const kaputt = dokument({
struktur: {
variablen: [],
abschnitte: [
{ titel: "01 · Start", schritte: [{ titel: "Anfangen" }] },
{
titel: "02 · Konto",
schritte: [{ titel: "Erledigen" }, { titel: "Prüfen" }, { titel: " " }],
},
],
},
});
await expect(vorlageImportieren(kaputt, "wer")).rejects.toThrow(
"struktur.abschnitte.1.schritte.2.titel: Ein Schritt braucht einen Titel.",
);
});
it("weist doppelte Variablenschluessel ab und nennt den Schluessel", async () => {
const doppelt = dokument({
struktur: {
variablen: [
{ schluessel: "vorname", beschriftung: "Vorname" },
{ schluessel: "vorname", beschriftung: "Rufname" },
],
abschnitte: [],
},
});
await expect(vorlageImportieren(doppelt, "wer")).rejects.toThrow(/„vorname“ gibt es zweimal/);
});
it("weist ungueltige Variablenschluessel ab", async () => {
// Der Schluessel steht als {{schluessel}} in Texten; ein Grossbuchstabe oder
// ein Strich liesse sich dort nicht sauber wiedererkennen.
for (const schluessel of ["Vorname", "mit-strich", "", "1vorname", "mit punkt"]) {
const falsch = dokument({
struktur: { variablen: [{ schluessel, beschriftung: "Egal" }], abschnitte: [] },
});
await expect(vorlageImportieren(falsch, "wer")).rejects.toThrow(AustauschFehler);
}
});
it("weist einen Doku-Link ab, der kein http ist", async () => {
// Der Doku-Link landet in der Abarbeitungsansicht als `href`. Ein
// `javascript:`-Link darin waere fremder Code im Browser des Technikers –
// eingespielt ueber eine Datei, die ein Kunde geschickt hat.
for (const link of [
"javascript:alert(1)",
"JavaScript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"intern.example/doku",
]) {
const kaputt = dokument({
struktur: {
variablen: [],
abschnitte: [{ titel: "Start", schritte: [{ titel: "Anfangen", dokuUrl: link }] }],
},
});
await expect(vorlageImportieren(kaputt, "wer"), link).rejects.toThrow(/http/);
}
});
it("nimmt http und https an", async () => {
for (const link of ["http://intern.example/doku", "https://intern.example/doku"]) {
const gut = dokument({
struktur: {
variablen: [],
abschnitte: [{ titel: "Start", schritte: [{ titel: "Anfangen", dokuUrl: link }] }],
},
});
// Ohne Datenbank kommt es bis zum Schreiben; abgewiesen wird es nicht.
await expect(vorlageImportieren(gut, "wer")).rejects.not.toThrow(/http/);
}
});
it("weist ein Dokument ab, das die Mengengrenzen sprengt", async () => {
// Eine Datei mit 100 000 Schritten ist kein Import, sondern ein
// Erschoepfungsangriff auf die Datenbank.
const zuViele = dokument({
struktur: {
variablen: [],
abschnitte: Array.from({ length: GRENZEN.abschnitte + 1 }, (_, i) => ({
titel: `Abschnitt ${i}`,
schritte: [],
})),
},
});
await expect(vorlageImportieren(zuViele, "wer")).rejects.toThrow(
new RegExp(`${GRENZEN.abschnitte} Abschnitte`),
);
const zuLang = dokument({
struktur: {
variablen: [],
abschnitte: [{ titel: "Start", schritte: [{ titel: "x".repeat(GRENZEN.text + 1) }] }],
},
});
await expect(vorlageImportieren(zuLang, "wer")).rejects.toThrow(AustauschFehler);
});
it("weist einen unbekannten Schritt-Typ ab", async () => {
// Ein unbekannter Typ wuerde in der Abarbeitung zu einem Schritt ohne
// Eingabefeld – der Techniker koennte ihn nie erledigen.
const kaputt = dokument({
struktur: {
variablen: [],
abschnitte: [{ titel: "Start", schritte: [{ titel: "Anfangen", typ: "AKTION" }] }],
},
});
await expect(vorlageImportieren(kaputt, "wer")).rejects.toThrow(AustauschFehler);
});
it("nennt bei einem ungueltigen Schluessel den Pfad und die Regel", async () => {
const falsch = dokument({
struktur: { variablen: [{ schluessel: "Vorname", beschriftung: "Egal" }], abschnitte: [] },
});
await expect(vorlageImportieren(falsch, "wer")).rejects.toThrow(
/^struktur\.variablen\.0\.schluessel: Ein Variablenschlüssel beginnt/,
);
});
});
describe.runIf(datenbankVerfuegbar)("Vorlagen-Austausch", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Export", () => {
it("liefert Abschnitte, Schritte und Variablen in der Reihenfolge der Position", async () => {
const versionId = await beispielVersionAnlegen();
const austausch = await vorlageExportieren(versionId);
expect(austausch.format).toBe(1);
expect(austausch.name).toBe("Onboarding Notebook");
expect(austausch.kategorie).toBe("Onboarding");
expect(austausch.schlagworte).toEqual(["notebook", "eintritt"]);
expect(austausch.version).toBe(3);
expect(austausch.struktur.variablen.map((v) => v.schluessel)).toEqual(["vorname", "abteilung"]);
expect(austausch.struktur.abschnitte.map((a) => a.titel)).toEqual(["01 · Vorbereitung", "02 · Konto"]);
expect(austausch.struktur.abschnitte[0]?.schritte.map((s) => s.titel)).toEqual([
"Gerät auspacken",
"Seriennummer erfassen",
"Arbeitsspeicher prüfen",
]);
expect(austausch.struktur.abschnitte[1]?.schritte.map((s) => s.typ)).toEqual(["BEFEHL", "DATUM"]);
});
it("enthaelt keine Kennungen, Positionen und keinen Kundenbezug", async () => {
const versionId = await beispielVersionAnlegen();
const austausch = await vorlageExportieren(versionId);
const rohtext = JSON.stringify(austausch);
expect(rohtext).not.toContain(versionId);
for (const feld of ['"id"', "versionId", "abschnittId", "position", "kundeId", "erstelltAm"]) {
expect(rohtext).not.toContain(feld);
}
});
it("wirft bei einer unbekannten Version", async () => {
await expect(vorlageExportieren("gibt-es-nicht")).rejects.toThrow(AustauschFehler);
});
});
describe("Import", () => {
it("legt eine neue Vorlage mit Version 1 im Status ENTWURF an", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const austausch = await vorlageExportieren(quelle);
const { vorlageId, versionId } = await vorlageImportieren(austausch, redakteur.id);
// Die Quelle bleibt unberuehrt, es gibt jetzt zwei Vorlagen.
expect(await testPrisma.vorlage.count()).toBe(2);
const version = await testPrisma.vorlagenVersion.findUniqueOrThrow({
where: { id: versionId },
});
expect(version.vorlageId).toBe(vorlageId);
expect(version.version).toBe(1);
expect(version.status).toBe("ENTWURF");
expect(version.veroeffentlichtAm).toBeNull();
expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId } })).toBe(1);
});
it("gibt einer eingespielten Kopie keinen Seed-Schluessel", async () => {
// Sonst hielte der Seed die Kopie fuer das Original und legte das Original
// ein zweites Mal an (#021). Geprueft wird mit einem Dokument, das den
// Schluessel ausdruecklich mitbringt - ein Angreifer oder ein Versehen
// koennte ihn ja hineinschreiben.
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const austausch = await vorlageExportieren(quelle);
const { vorlageId } = await vorlageImportieren(
{ ...austausch, seedSchluessel: "pc-neuinstallation" },
redakteur.id,
);
const kopie = await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlageId } });
expect(kopie.seedSchluessel).toBeNull();
});
it("leitet die Positionen aus der Reihenfolge der Listen ab", async () => {
const redakteur = await redakteurAnlegen();
const { versionId } = await vorlageImportieren(
dokument({
struktur: {
variablen: [
{ schluessel: "vorname", beschriftung: "Vorname" },
{ schluessel: "nachname", beschriftung: "Nachname" },
],
abschnitte: [
{ titel: "01 · Start", schritte: [{ titel: "Eins" }, { titel: "Zwei" }] },
{ titel: "02 · Ende", schritte: [{ titel: "Drei" }] },
],
},
}),
redakteur.id,
);
const variablen = await testPrisma.vorlagenVariable.findMany({
where: { versionId },
orderBy: { position: "asc" },
});
expect(variablen.map((v) => [v.schluessel, v.position])).toEqual([
["vorname", 0],
["nachname", 1],
]);
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId },
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
});
expect(abschnitte.map((a) => a.position)).toEqual([0, 1]);
expect(abschnitte[0]?.schritte.map((s) => [s.titel, s.position])).toEqual([
["Eins", 0],
["Zwei", 1],
]);
expect(abschnitte[1]?.schritte.map((s) => s.position)).toEqual([0]);
});
it("nimmt einen Namen, den es schon gibt, ohne Murren an", async () => {
const redakteur = await redakteurAnlegen();
await vorlageImportieren(dokument(), redakteur.id);
await vorlageImportieren(dokument(), redakteur.id);
const gleichnamige = await testPrisma.vorlage.findMany({ where: { name: "Kleine Vorlage" } });
expect(gleichnamige).toHaveLength(2);
// Kein angehaengtes "(2)": der Name kommt so an, wie er im Dokument steht.
expect(gleichnamige.map((v) => v.name)).toEqual(["Kleine Vorlage", "Kleine Vorlage"]);
});
it("schreibt einen Protokolleintrag", async () => {
const redakteur = await redakteurAnlegen();
const { vorlageId } = await vorlageImportieren(dokument(), redakteur.id);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "Vorlage", entitaetId: vorlageId },
});
expect(eintraege).toHaveLength(1);
expect(eintraege[0]?.aktion).toBe("Vorlage importiert");
expect(eintraege[0]?.benutzerId).toBe(redakteur.id);
});
it("hinterlaesst nach einem Fehlschlag keine halbe Vorlage", async () => {
// Ein unbekannter Benutzer laesst den Protokolleintrag am Fremdschluessel
// scheitern – also erst, nachdem Vorlage, Version und Schritte geschrieben
// sind. Genau dieser Fall zeigt, dass die Transaktion greift.
await expect(vorlageImportieren(dokument(), "gibt-es-nicht")).rejects.toThrow();
expect(await testPrisma.vorlage.count()).toBe(0);
expect(await testPrisma.vorlagenVersion.count()).toBe(0);
expect(await testPrisma.vorlagenAbschnitt.count()).toBe(0);
expect(await testPrisma.vorlagenSchritt.count()).toBe(0);
expect(await testPrisma.vorlagenVariable.count()).toBe(0);
expect(await testPrisma.protokoll.count()).toBe(0);
});
it("schreibt nichts, wenn das Dokument abgewiesen wird", async () => {
await expect(vorlageImportieren(dokument({ format: 99 }), "wer")).rejects.toThrow(AustauschFehler);
expect(await testPrisma.vorlage.count()).toBe(0);
});
});
describe("Seed-Schluessel", () => {
it("steht in keinem Export – auch nicht bei einer mitgelieferten Vorlage", async () => {
// Ein Feld, das beim Einlesen verfaellt, gehoert nicht in die Datei: wer es
// dort sieht, haelt es fuer uebertragbar.
const quelle = await beispielVersionAnlegen();
await testPrisma.vorlage.updateMany({ data: { seedSchluessel: "pc-neuinstallation" } });
const austausch = await vorlageExportieren(quelle);
expect(austausch).not.toHaveProperty("seedSchluessel");
expect(JSON.stringify(austausch)).not.toContain("pc-neuinstallation");
});
});
describe("Rundlauf", () => {
it("ergibt ein inhaltsgleiches Dokument – bis auf die Version", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const erst = await vorlageExportieren(quelle);
const { versionId } = await vorlageImportieren(erst, redakteur.id);
const wieder = await vorlageExportieren(versionId);
expect(ohneVersion(wieder)).toEqual(ohneVersion(erst));
// Die Quelle stand auf Version 3, die eingespielte Vorlage faengt bei 1 an.
expect(erst.version).toBe(3);
expect(wieder.version).toBe(1);
});
it("laesst einen BEFEHL-Schritt samt Platzhaltern unveraendert", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[1]?.schritte[0];
expect(schritt?.typ).toBe("BEFEHL");
expect(schritt?.konfig["shell"]).toBe("powershell");
expect(schritt?.konfig["befehl"]).toBe(BEFEHL);
});
it("bewahrt unbekannte konfig-Schluessel", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[1]?.schritte[0];
expect(schritt?.konfig["kuenftigesFeld"]).toEqual({ tief: [1, 2] });
});
it("bewahrt die Abteilungseinschränkung eines Abschnitts", async () => {
/*
* **Das ging stillschweigend verloren.** Der Export schreibt
* `nurFuerAbteilungen` seit #031, das Schema nimmt es an — und der Import
* verwarf es. Eine Vorlage, die jemand in eine zweite Installation
* brachte, verlor ihre Abteilungsabschnitte: sie galten dort für **alle**.
* Also mehr Schritte statt weniger, und niemand merkt es. Gefunden bei
* #039, als derselbe Weg für Bedingungen gebaut wurde.
*/
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen(async (versionId) => {
await testPrisma.vorlagenAbschnitt.updateMany({
where: { versionId, position: 0 },
data: { nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"] },
});
});
const erst = await vorlageExportieren(quelle);
expect(erst.struktur.abschnitte[0]?.nurFuerAbteilungen).toEqual(["Buchhaltung", "Vertrieb"]);
const { versionId } = await vorlageImportieren(erst, redakteur.id);
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId, position: 0 },
});
expect(zeile.nurFuerAbteilungen).toEqual(["Buchhaltung", "Vertrieb"]);
});
it("schreibt eine Schritt-Bedingung als Titel-Pfad und löst sie wieder auf", async () => {
/*
* Eine Bedingung zeigt gespeichert auf eine **Kennung**, und die bedeutet
* in einer anderen Installation nichts. Im Austausch steht deshalb ein
* Titel-Pfad; beim Import wird daraus die neue Kennung — sonst wäre die
* Bedingung dort nicht entscheidbar, und der Abschnitt erschiene immer,
* ohne dass jemand sagen könnte, warum (#039).
*/
const redakteur = await redakteurAnlegen();
let abschnitte: Awaited<ReturnType<typeof abschnitteVon>> = [];
const quelle = await beispielVersionAnlegen(async (versionId) => {
abschnitte = await abschnitteVon(versionId);
await testPrisma.vorlagenAbschnitt.update({
where: { id: abschnitte[1]!.id },
data: {
bedingung: {
art: "schritt",
schrittId: abschnitte[0]!.schritte[0]!.id,
zustaende: ["FEHLGESCHLAGEN"],
},
},
});
});
const ziel = abschnitte[0]!.schritte[0]!;
// Im Export steht der Pfad, nicht die Kennung.
const erst = await vorlageExportieren(quelle);
const ausgetauscht = erst.struktur.abschnitte[1]?.bedingung;
expect(ausgetauscht).toEqual({
art: "schritt",
abschnitt: abschnitte[0]!.titel,
schritt: ziel.titel,
zustaende: ["FEHLGESCHLAGEN"],
});
expect(JSON.stringify(erst)).not.toContain(ziel.id);
// Und beim Import wird daraus die neue Kennung.
const { versionId } = await vorlageImportieren(erst, redakteur.id);
const neu = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId },
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
});
expect(neu[1]?.bedingung).toEqual({
art: "schritt",
schrittId: neu[0]!.schritte[0]!.id,
zustaende: ["FEHLGESCHLAGEN"],
});
});
it("übergeht eine Bedingung, deren Titel-Pfad in der Datei fehlt", async () => {
/*
* Eine von Hand geschriebene Datei kann sich vertippen. Die ganze Vorlage
* deswegen abzuweisen wäre die teurere Antwort — übergangen heißt: der
* Abschnitt gilt immer, und das steht sichtbar in der Oberfläche.
*/
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const erst = await vorlageExportieren(quelle);
const { versionId } = await vorlageImportieren(
{
...erst,
name: "Mit Tippfehler",
struktur: {
...erst.struktur,
abschnitte: erst.struktur.abschnitte.map((abschnitt, stelle) =>
stelle === 1
? {
...abschnitt,
bedingung: {
art: "schritt",
abschnitt: "Gibt es nicht",
schritt: "Auch nicht",
zustaende: ["ERLEDIGT"],
},
}
: abschnitt,
),
},
},
redakteur.id,
);
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId, position: 1 },
});
expect(zeile.bedingung).toBeNull();
});
it("bewahrt eine Variablen-Bedingung unverändert", async () => {
const redakteur = await redakteurAnlegen();
const bedingung = { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" };
const quelle = await beispielVersionAnlegen(async (versionId) => {
await testPrisma.vorlagenAbschnitt.updateMany({
where: { versionId, position: 0 },
data: { bedingung },
});
});
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId, position: 0 },
});
// Ein Variablenschluessel ist ein Name, den die Vorlage selbst mitbringt —
// er wandert unveraendert.
expect(zeile.bedingung).toEqual(bedingung);
});
it("bewahrt Hilfetext, Doku-Link und Zahlengrenzen", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
const schritte = (await vorlageExportieren(versionId)).struktur.abschnitte[0]?.schritte;
expect(schritte?.[1]?.hilfetext).toBe("Aufkleber auf der Unterseite.");
expect(schritte?.[1]?.dokuUrl).toBe("https://intern.example/seriennummern");
expect(schritte?.[2]?.konfig).toEqual({ min: 8, max: 64, einheit: "GB" });
});
});
describe("Export unsauberer Daten", () => {
/** Legt einen einzelnen Schritt mit frei gewaehltem `konfig` an. */
async function schrittMitKonfig(konfig: unknown): Promise<string> {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Krumm" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Abschnitt" },
});
await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Schritt",
typ: "BEFEHL",
konfig: konfig as never,
},
});
return version.id;
}
it("verliert bei einer unbekannten Shell nicht den Befehl", async () => {
// Das war ein echter Befund: `safeParse` verwarf bei jeder Beanstandung
// das ganze `konfig` – eine Shell aus einer spaeteren Fassung kostete
// lautlos auch den Befehl. Genau die Datei, die jemand zum Umzug braucht.
const versionId = await schrittMitKonfig({ shell: "zsh", befehl: "echo hallo" });
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[0]?.schritte[0];
expect(schritt?.konfig["befehl"]).toBe("echo hallo");
expect(schritt?.konfig["shell"]).toBe("zsh");
});
it("liefert für konfig-Werte, die gar kein Objekt sind, ein leeres Objekt", async () => {
for (const krumm of ["kaputt", 42, [1, 2], true]) {
const versionId = await schrittMitKonfig(krumm);
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[0]?.schritte[0];
expect(schritt?.konfig, JSON.stringify(krumm)).toEqual({});
await datenbankLeeren();
}
});
it("lässt sich mit unbekanntem konfig-Schlüssel wieder einspielen", async () => {
// Der Export muss nicht nur gelingen, er muss auch durch die Pruefung des
// Imports kommen – sonst waere er eine Datei, die niemand nutzen kann.
const redakteur = await redakteurAnlegen();
const versionId = await schrittMitKonfig({ shell: "bash", befehl: "echo", tief: null });
const { versionId: neu } = await vorlageImportieren(await vorlageExportieren(versionId), redakteur.id);
const schritt = (await vorlageExportieren(neu)).struktur.abschnitte[0]?.schritte[0];
expect(schritt?.konfig["befehl"]).toBe("echo");
expect(schritt?.konfig["tief"]).toBeNull();
});
it("weist eine unbekannte Shell beim Einspielen laut ab", async () => {
// Hier laufen zwei Regeln absichtlich auseinander, und das ist die
// Entscheidung: der **Export** gibt eine unbekannte Shell heraus, damit die
// Datei nichts verliert; der **Import** weist sie ab, weil `shell` darueber
// entscheidet, wie ein Wert maskiert wird. Eine stillschweigend verworfene
// Shell hiesse, einen bash-Befehl nach PowerShell-Regeln zu maskieren – das
// kann auf einem Server Daten kosten. Die Meldung nennt Pfad und erlaubte
// Werte, also kann der Redakteur die Datei richtigstellen.
const redakteur = await redakteurAnlegen();
const versionId = await schrittMitKonfig({ shell: "zsh", befehl: "echo hallo" });
const dokument = await vorlageExportieren(versionId);
await expect(vorlageImportieren(dokument, redakteur.id)).rejects.toThrow(/schritte\.0\.konfig\.shell/);
await expect(vorlageImportieren(dokument, redakteur.id)).rejects.toThrow(/powershell/);
// Nur die eine Quellvorlage steht da, keine halb eingespielte zweite.
expect(await testPrisma.vorlage.count()).toBe(1);
});
it("weist einen bereits gespeicherten javascript-Link beim Wiedereinspielen ab", async () => {
// Aeltere Zeilen koennten so einen Link tragen, denn die Pruefung kam erst
// mit #009. Der Export gibt ihn heraus, der Import faengt ihn – die Datei
// ist dann ein sichtbarer Fehlerfall und kein stiller Durchlaeufer.
const redakteur = await redakteurAnlegen();
const vorlage = await testPrisma.vorlage.create({ data: { name: "Alt" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Abschnitt" },
});
await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Schritt", dokuUrl: "javascript:alert(1)" },
});
const dokument = await vorlageExportieren(version.id);
expect(dokument.struktur.abschnitte[0]?.schritte[0]?.dokuUrl).toBe("javascript:alert(1)");
await expect(vorlageImportieren(dokument, redakteur.id)).rejects.toThrow(/http/);
expect(await testPrisma.vorlage.count()).toBe(1);
});
});
});