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>
765 lines
31 KiB
TypeScript
765 lines
31 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|