feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch
Eine veroeffentlichte Vorlagen-Version ist fuer immer unveraenderlich — daran haengt, dass ein archivierter Lauf in zehn Jahren noch genau die Schritte zeigt, mit denen er gestartet wurde. Die Regel steht deshalb zweimal: im Service, der die verstaendliche Meldung gibt, und in der Datenbank, die die Garantie gibt. Vier Trigger fangen Schreibzugriffe ab, die nicht durch den Service kommen — Migrationen, psql, der Seed und ab Phase 4 die Connectoren — und versperren den Rueckweg nach ENTWURF, der alles andere ausgehebelt haette. - Service: anlegen, Kopfdaten bearbeiten, Entwurf als tiefe Kopie der letzten veroeffentlichten Version, speichern, veroeffentlichen, verwerfen, duplizieren, archivieren - Hoechstens ein Entwurf je Vorlage, durchgesetzt vom partiellen Index; zwei gleichzeitige Klicks auf „Bearbeiten“ bekommen denselben Entwurf statt eines Fehlers - Optimistisches Sperren ueber geaendertAm: wer auf einem ueberholten Stand speichert, wird abgewiesen statt die Arbeit des anderen spurlos zu ersetzen - JSON-Export und -Import samt Format-Dokumentation; der Export ist nachsichtig, der Import streng - src/lib/validierung.ts als einzige Zod-Quelle mit deutschen Meldungen, per ESLint-Regel erzwungen Der unabhaengige Review fand drei Blocker, alle an der Mandantengrenze: der Suchfilter brachte ein zweites OR mit und ueberschrieb die Kundenbedingung, Historie und Aufbau einer Version filterten gar nicht. Zu queries/vorlagen.ts gab es keinen Test — jetzt gibt es tests/vorlagen-sicht.test.ts. 428 Tests gruen (vorher 336). Schliesst #009 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ecc3c579df
commit
abacad1cb7
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Wächter über die Datenbankobjekte, die Prisma nicht kennt (#009).
|
||||
*
|
||||
* Zusammengesetzte Fremdschlüssel, Trigramm-Indizes und Trigger lassen sich im
|
||||
* Prisma-Schema nicht ausdrücken. Deshalb erzeugt `prisma migrate dev` bei
|
||||
* **jeder** künftigen Migration Löschbefehle für sie — sie sind aus Prismas
|
||||
* Sicht Abweichungen von der Vorgabe. Wer die Löschbefehle einmal übersieht,
|
||||
* entfernt stillschweigend die Mandantengrenze, die Archivsuche oder den Schutz
|
||||
* der Unveränderlichkeit.
|
||||
*
|
||||
* Dieser Test macht das laut. Er prüft nicht, dass die Regeln *wirken* — das tun
|
||||
* `tests/datenmodell.test.ts` und `tests/vorlagen.test.ts` —, sondern nur, dass
|
||||
* die Objekte überhaupt noch da sind.
|
||||
*/
|
||||
|
||||
async function namen(abfrage: string): Promise<string[]> {
|
||||
const zeilen = await testPrisma.$queryRawUnsafe<{ name: string }[]>(abfrage);
|
||||
return zeilen.map((zeile) => zeile.name).sort();
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
||||
it("hat die zusammengesetzten Fremdschlüssel der Mandantengrenze", async () => {
|
||||
// Ohne sie kann ein Lauf ein Gerät eines fremden Kunden ziehen.
|
||||
expect(
|
||||
await namen(`SELECT conname AS name FROM pg_constraint WHERE conname LIKE '%gleicher_kunde'`),
|
||||
).toEqual([
|
||||
"Geraet_besitzer_gleicher_kunde",
|
||||
"Lauf_abteilung_gleicher_kunde",
|
||||
"Lauf_geraet_gleicher_kunde",
|
||||
"Lauf_mitarbeiter_gleicher_kunde",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hat die Trigramm-Indizes für die Archivsuche", async () => {
|
||||
// Ohne sie wird jede Archivsuche zum vollständigen Durchlauf der Tabelle.
|
||||
expect(
|
||||
await namen(
|
||||
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%gin_trgm_ops%'`,
|
||||
),
|
||||
).toEqual([
|
||||
"Kommentar_rumpf_trgm_idx",
|
||||
"Lauf_titel_trgm_idx",
|
||||
"Schrittergebnis_notiz_trgm_idx",
|
||||
"Schrittergebnis_wert_trgm_idx",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hat die Trigger, die eine veröffentlichte Version schützen", async () => {
|
||||
expect(await namen(`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal`)).toEqual([
|
||||
"VorlagenAbschnitt_unveraenderlich",
|
||||
"VorlagenSchritt_unveraenderlich",
|
||||
"VorlagenVariable_unveraenderlich",
|
||||
"VorlagenVersion_status_einbahn",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hat die partiellen Eindeutigkeitsindizes", async () => {
|
||||
expect(
|
||||
await namen(
|
||||
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%WHERE%'`,
|
||||
),
|
||||
).toEqual(["Kunde_eigene_organisation_einmalig", "VorlagenVersion_ein_entwurf"]);
|
||||
});
|
||||
|
||||
it("hat alle Prüfbedingungen", async () => {
|
||||
const vorhanden = await namen(
|
||||
`SELECT conname AS name FROM pg_constraint WHERE contype='c' AND connamespace='public'::regnamespace`,
|
||||
);
|
||||
// Namentlich aufgezählt, damit auch das Verschwinden einer einzelnen auffällt.
|
||||
expect(vorhanden).toEqual([
|
||||
"Einstellung_einzelne_zeile",
|
||||
"Geraet_registriert_braucht_namen",
|
||||
"Geraet_tagcode_form",
|
||||
"Kunde_kuerzel_form",
|
||||
"Lauf_abgeschlossen_mit_zeitpunkt",
|
||||
"Lauf_archiviert_mit_zeitpunkt",
|
||||
"Lauf_begonnen_mit_zeitpunkt",
|
||||
"Lauf_mitarbeiter_eindeutig",
|
||||
"Schrittergebnis_begruendung_noetig",
|
||||
"TagCodeVergabe_form",
|
||||
"Verzeichnisquelle_bindpasswort_verschluesselt",
|
||||
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hat die Erweiterung für die Trigramm-Suche", async () => {
|
||||
const zeilen = await testPrisma.$queryRaw<{ extname: string }[]>`
|
||||
SELECT extname FROM pg_extension WHERE extname = 'pg_trgm'
|
||||
`;
|
||||
expect(zeilen).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -22,18 +22,28 @@ async function benutzerAnlegen(email = "techniker@firma.example") {
|
||||
});
|
||||
}
|
||||
|
||||
/** Kleinste Vorlage, die ausreicht, um einen Lauf daran zu haengen. */
|
||||
/**
|
||||
* Kleinste Vorlage, die ausreicht, um einen Lauf daran zu haengen.
|
||||
*
|
||||
* Erst als Entwurf anlegen, dann veroeffentlichen – an einer veroeffentlichten
|
||||
* Version laesst sich nichts mehr anhaengen, dafuer sorgen Trigger in der
|
||||
* Datenbank. Wer die Fixture umdreht, baut genau den Zustand, den die Anwendung
|
||||
* nie herstellen darf.
|
||||
*/
|
||||
async function veroeffentlichteVersionAnlegen() {
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } });
|
||||
return testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Schliesst eine Fixture-Version ab, nachdem ihr Aufbau steht. */
|
||||
async function versionVeroeffentlichen(versionId: string) {
|
||||
return testPrisma.vorlagenVersion.update({
|
||||
where: { id: versionId },
|
||||
// veroeffentlichtAm ist Pflicht, sobald der Status VEROEFFENTLICHT ist –
|
||||
// eine Bedingung in der Datenbank haelt das fest.
|
||||
data: {
|
||||
vorlageId: vorlage.id,
|
||||
version: 1,
|
||||
status: "VEROEFFENTLICHT",
|
||||
veroeffentlichtAm: new Date(),
|
||||
},
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -291,6 +301,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell: Regeln in der Datenbank", () =
|
||||
const schritt = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst", pflicht: true },
|
||||
});
|
||||
await versionVeroeffentlichen(version.id);
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
@@ -430,6 +441,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell: Grenzen und Zusicherungen", ()
|
||||
const schritt = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
|
||||
});
|
||||
await versionVeroeffentlichen(version.id);
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
@@ -527,6 +539,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell: Grenzen und Zusicherungen", ()
|
||||
const schritt = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
|
||||
});
|
||||
await versionVeroeffentlichen(version.id);
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
@@ -565,6 +578,7 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell: Grenzen und Zusicherungen", ()
|
||||
const schritt = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "New-ADUser", typ: "BEFEHL" },
|
||||
});
|
||||
await versionVeroeffentlichen(version.id);
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { AustauschSchema, StrukturSchema } from "@/lib/vorlagen-format";
|
||||
|
||||
/**
|
||||
* Zod ist in src/lib/validierung.ts auf Deutsch eingestellt (#009).
|
||||
*
|
||||
* Ohne diese Einstellung landen englische Standardsaetze ueber die Server
|
||||
* Actions in der Oberflaeche — und zwar genau dort, wo niemand an eine eigene
|
||||
* Meldung gedacht hat.
|
||||
*/
|
||||
describe("Deutsche Meldungen", () => {
|
||||
it("meldet einen falschen Typ auf Deutsch", () => {
|
||||
const ergebnis = z.object({ name: z.string() }).safeParse({ name: 42 });
|
||||
expect(ergebnis.error?.issues[0]?.message).toMatch(/Ungültige Eingabe/);
|
||||
});
|
||||
|
||||
it("meldet ein fehlendes Pflichtfeld auf Deutsch", () => {
|
||||
const ergebnis = z.object({ name: z.string() }).safeParse({});
|
||||
expect(ergebnis.error?.issues[0]?.message).not.toMatch(/Invalid input/);
|
||||
});
|
||||
|
||||
it("laesst eigene Meldungen unangetastet", () => {
|
||||
const ergebnis = StrukturSchema.safeParse({
|
||||
abschnitte: [{ titel: "A", schritte: [{ titel: "" }] }],
|
||||
});
|
||||
expect(ergebnis.error?.issues[0]?.message).toBe("Ein Schritt braucht einen Titel.");
|
||||
});
|
||||
|
||||
it("meldet ein unbekanntes Austauschformat auf Deutsch", () => {
|
||||
const ergebnis = AustauschSchema.safeParse({ format: 99, name: "X", struktur: {} });
|
||||
expect(ergebnis.error?.issues[0]?.message).toMatch(/Unbekanntes Format/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,578 @@
|
||||
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.
|
||||
*/
|
||||
async function beispielVersionAnlegen(): 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 testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
return version.id;
|
||||
}
|
||||
|
||||
/** 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("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("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 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import {
|
||||
versionMitAufbau,
|
||||
versionsHistorie,
|
||||
vorlagenFilterwerte,
|
||||
vorlagenListe,
|
||||
} from "@/server/queries/vorlagen";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Mandantensicht auf die Vorlagen (#009).
|
||||
*
|
||||
* Diese Datei gibt es, weil der unabhängige Review zwei Lücken gefunden hat, die
|
||||
* kein Test sehen konnte: der Suchfilter hat die Kundenbedingung überschrieben
|
||||
* (zwei `OR`-Schlüssel im selben Objekt, der zweite gewinnt), und Historie wie
|
||||
* Aufbau einer Version haben überhaupt nicht gefiltert. Beides fiel nicht auf,
|
||||
* weil es zu `src/server/queries/vorlagen.ts` keinen einzigen Test gab.
|
||||
*/
|
||||
|
||||
async function aufbau() {
|
||||
const eigene = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
|
||||
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
|
||||
await testPrisma.einstellung.create({
|
||||
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
|
||||
});
|
||||
|
||||
const techniker = await testPrisma.benutzer.create({
|
||||
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
|
||||
});
|
||||
await testPrisma.benutzerKunde.create({
|
||||
data: { benutzerId: techniker.id, kundeId: meiner.id },
|
||||
});
|
||||
|
||||
async function vorlageMit(name: string, kundeId: string | null) {
|
||||
const vorlage = await testPrisma.vorlage.create({
|
||||
data: { name, kategorie: "Geheim", schlagworte: ["intern"], kundeId },
|
||||
});
|
||||
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 von ${name}` },
|
||||
});
|
||||
await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Ein Schritt" },
|
||||
});
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
});
|
||||
return { vorlage, versionId: version.id };
|
||||
}
|
||||
|
||||
return {
|
||||
techniker,
|
||||
admin: { id: "admin", rolle: "ADMIN" as const },
|
||||
meine: await vorlageMit("Geheimprojekt Meiner", meiner.id),
|
||||
fremde: await vorlageMit("Geheimprojekt Fremder", fremder.id),
|
||||
globale: await vorlageMit("Geheimprojekt Global", null),
|
||||
eigene,
|
||||
};
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Mandantensicht auf Vorlagen", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
|
||||
});
|
||||
|
||||
describe("Liste", () => {
|
||||
it("zeigt einem gebundenen Techniker nur seine und die globalen Vorlagen", async () => {
|
||||
const { techniker } = await aufbau();
|
||||
const namen = (await vorlagenListe(techniker)).map((z) => z.name).sort();
|
||||
expect(namen).toEqual(["Geheimprojekt Global", "Geheimprojekt Meiner"]);
|
||||
});
|
||||
|
||||
it("lässt die Mandantengrenze auch beim Suchen stehen", async () => {
|
||||
// Genau der Blocker: der Suchfilter brachte ein zweites `OR` mit und
|
||||
// überschrieb die Kundenbedingung — ein Buchstabe im Suchfeld genügte.
|
||||
const { techniker } = await aufbau();
|
||||
const namen = (await vorlagenListe(techniker, { suche: "Geheim" })).map((z) => z.name).sort();
|
||||
expect(namen).toEqual(["Geheimprojekt Global", "Geheimprojekt Meiner"]);
|
||||
expect(namen).not.toContain("Geheimprojekt Fremder");
|
||||
});
|
||||
|
||||
it("lässt sie auch bei Kategorie- und Schlagwortfilter stehen", async () => {
|
||||
const { techniker } = await aufbau();
|
||||
for (const filter of [{ kategorie: "Geheim" }, { schlagwort: "intern" }]) {
|
||||
const namen = (await vorlagenListe(techniker, filter)).map((z) => z.name);
|
||||
expect(namen, JSON.stringify(filter)).not.toContain("Geheimprojekt Fremder");
|
||||
}
|
||||
});
|
||||
|
||||
it("lässt sie auch bei allen Filtern gleichzeitig stehen", async () => {
|
||||
const { techniker } = await aufbau();
|
||||
const namen = (
|
||||
await vorlagenListe(techniker, {
|
||||
suche: "Geheim",
|
||||
kategorie: "Geheim",
|
||||
schlagwort: "intern",
|
||||
auchArchivierte: true,
|
||||
})
|
||||
).map((z) => z.name);
|
||||
expect(namen).not.toContain("Geheimprojekt Fremder");
|
||||
});
|
||||
|
||||
it("zeigt einem Administrator alles", async () => {
|
||||
const { admin } = await aufbau();
|
||||
expect(await vorlagenListe(admin, { suche: "Geheim" })).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("blendet fremde Kategorien und Schlagworte aus den Filterwerten aus", async () => {
|
||||
const { techniker, fremde } = await aufbau();
|
||||
await testPrisma.vorlage.update({
|
||||
where: { id: fremde.vorlage.id },
|
||||
data: { kategorie: "Nur-fuer-Fremde", schlagworte: ["geheimwort"] },
|
||||
});
|
||||
const werte = await vorlagenFilterwerte(techniker);
|
||||
expect(werte.kategorien).not.toContain("Nur-fuer-Fremde");
|
||||
expect(werte.schlagworte).not.toContain("geheimwort");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Historie und Aufbau", () => {
|
||||
it("zeigt die Historie einer fremden Vorlage nicht", async () => {
|
||||
const { techniker, fremde, meine } = await aufbau();
|
||||
expect(await versionsHistorie(fremde.vorlage.id, techniker)).toEqual([]);
|
||||
expect(await versionsHistorie(meine.vorlage.id, techniker)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("gibt den Aufbau einer fremden Version nicht heraus", async () => {
|
||||
// Sonst stünden Abschnittstitel, Schritte und Befehle eines fremden Kunden
|
||||
// offen — die Vorlage ist der innere Ablauf seines Betriebs.
|
||||
const { techniker, fremde, meine, globale } = await aufbau();
|
||||
expect(await versionMitAufbau(fremde.versionId, techniker)).toBeUndefined();
|
||||
expect(await versionMitAufbau(meine.versionId, techniker)).toBeDefined();
|
||||
expect(await versionMitAufbau(globale.versionId, techniker)).toBeDefined();
|
||||
});
|
||||
|
||||
it("gibt einem Administrator alles heraus", async () => {
|
||||
const { admin, fremde } = await aufbau();
|
||||
expect(await versionMitAufbau(fremde.versionId, admin)).toBeDefined();
|
||||
});
|
||||
|
||||
it("schränkt ohne gesetztes Präfix niemanden ein", async () => {
|
||||
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
const { techniker, fremde } = await aufbau();
|
||||
expect(await versionMitAufbau(fremde.versionId, techniker)).toBeDefined();
|
||||
expect(await vorlagenListe(techniker)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,741 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
VorlagenFehler,
|
||||
entwurfHolenOderErzeugen,
|
||||
entwurfSpeichern,
|
||||
entwurfVerwerfen,
|
||||
veroeffentlichteVersionPruefen,
|
||||
versionMussEntwurfSein,
|
||||
versionVeroeffentlichen,
|
||||
vorlageAnlegen,
|
||||
vorlageArchivieren,
|
||||
vorlageBearbeiten,
|
||||
vorlageDuplizieren,
|
||||
} from "@/server/services/vorlagen";
|
||||
import type { StrukturEingabe } from "@/lib/vorlagen-format";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Versionierung der Vorlagen (#009).
|
||||
*
|
||||
* Die tragende Regel: eine veroeffentlichte Version wird nie wieder veraendert.
|
||||
* Ein Lauf zeigt deshalb auch in zehn Jahren genau die Schritte, die damals
|
||||
* abgearbeitet wurden. Alles hier dreht sich darum, dass diese Regel haelt.
|
||||
*/
|
||||
|
||||
const STRUKTUR: StrukturEingabe = {
|
||||
variablen: [
|
||||
{
|
||||
schluessel: "os_version",
|
||||
beschriftung: "Betriebssystem",
|
||||
typ: "AUSWAHL",
|
||||
optionen: ["Windows 11 Pro", "Windows 11 Enterprise"],
|
||||
pflicht: true,
|
||||
},
|
||||
{
|
||||
schluessel: "join_typ",
|
||||
beschriftung: "Einbindung",
|
||||
typ: "AUSWAHL",
|
||||
optionen: ["Active Directory", "Entra ID"],
|
||||
pflicht: true,
|
||||
},
|
||||
],
|
||||
abschnitte: [
|
||||
{
|
||||
titel: "Vorbereitung",
|
||||
schritte: [
|
||||
{ titel: "Ticket und Endbenutzer im Lauf erfasst", pflicht: true },
|
||||
{
|
||||
titel: "Datensicherung geprüft",
|
||||
hilfetext: "Auch nach lokal gespeicherten Daten außerhalb des Profils fragen.",
|
||||
pflicht: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
titel: "Installation",
|
||||
schritte: [
|
||||
{
|
||||
titel: "Hostname gesetzt",
|
||||
typ: "BEFEHL",
|
||||
pflicht: true,
|
||||
konfig: { shell: "powershell", befehl: 'Rename-Computer -NewName "{{hostname}}" -Restart' },
|
||||
},
|
||||
{ titel: "Build-Nummer", typ: "TEXT", pflicht: true, konfig: { mehrzeilig: false } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function benutzerAnlegen(email = "redakteur@firma.example") {
|
||||
return testPrisma.benutzer.create({
|
||||
data: { email, name: "Redakteur", rolle: "REDAKTEUR" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Eine Vorlage mit einer veroeffentlichten Version 1. */
|
||||
async function veroeffentlichteVorlage(benutzerId: string) {
|
||||
const { vorlage, version } = await vorlageAnlegen(
|
||||
{ name: "PC Neuinstallation", kategorie: "Client" },
|
||||
benutzerId,
|
||||
);
|
||||
await entwurfSpeichern(version.id, STRUKTUR);
|
||||
await versionVeroeffentlichen(version.id, "Erste Fassung", benutzerId);
|
||||
return { vorlage, versionId: version.id };
|
||||
}
|
||||
|
||||
async function strukturLesen(versionId: string) {
|
||||
const version = await testPrisma.vorlagenVersion.findUniqueOrThrow({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
variablen: { orderBy: { position: "asc" } },
|
||||
abschnitte: {
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: { orderBy: { position: "asc" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
return version;
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
describe("Anlegen", () => {
|
||||
it("legt eine Vorlage mit einem leeren Entwurf als Version 1 an", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage, version } = await vorlageAnlegen(
|
||||
{ name: " Mitarbeiter-Onboarding ", kategorie: "Benutzer", schlagworte: ["ad", "entra"] },
|
||||
benutzer.id,
|
||||
);
|
||||
|
||||
expect(vorlage.name).toBe("Mitarbeiter-Onboarding");
|
||||
expect(vorlage.schlagworte).toEqual(["ad", "entra"]);
|
||||
expect(version.version).toBe(1);
|
||||
expect(version.status).toBe("ENTWURF");
|
||||
expect(version.veroeffentlichtAm).toBeNull();
|
||||
});
|
||||
|
||||
it("weist einen leeren Namen ab", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
await expect(vorlageAnlegen({ name: " " }, benutzer.id)).rejects.toBeInstanceOf(VorlagenFehler);
|
||||
expect(await testPrisma.vorlage.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Entwurf speichern", () => {
|
||||
it("legt Variablen, Abschnitte und Schritte in der übergebenen Reihenfolge an", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
|
||||
await entwurfSpeichern(version.id, STRUKTUR);
|
||||
|
||||
const gelesen = await strukturLesen(version.id);
|
||||
expect(gelesen.variablen.map((v) => v.schluessel)).toEqual(["os_version", "join_typ"]);
|
||||
expect(gelesen.variablen.map((v) => v.position)).toEqual([0, 1]);
|
||||
expect(gelesen.abschnitte.map((a) => a.titel)).toEqual(["Vorbereitung", "Installation"]);
|
||||
expect(gelesen.abschnitte[1]?.schritte.map((s) => s.titel)).toEqual([
|
||||
"Hostname gesetzt",
|
||||
"Build-Nummer",
|
||||
]);
|
||||
expect(gelesen.abschnitte[1]?.schritte[0]?.konfig).toMatchObject({
|
||||
shell: "powershell",
|
||||
befehl: 'Rename-Computer -NewName "{{hostname}}" -Restart',
|
||||
});
|
||||
});
|
||||
|
||||
it("ersetzt den Aufbau vollständig, statt ihn zu ergänzen", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
|
||||
await entwurfSpeichern(version.id, STRUKTUR);
|
||||
await entwurfSpeichern(version.id, {
|
||||
variablen: [],
|
||||
abschnitte: [{ titel: "Nur noch das hier", schritte: [{ titel: "Ein Schritt" }] }],
|
||||
});
|
||||
|
||||
const gelesen = await strukturLesen(version.id);
|
||||
expect(gelesen.variablen).toHaveLength(0);
|
||||
expect(gelesen.abschnitte).toHaveLength(1);
|
||||
expect(await testPrisma.vorlagenSchritt.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("weist einen doppelten Variablenschlüssel mit verständlicher Meldung ab", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
|
||||
await expect(
|
||||
entwurfSpeichern(version.id, {
|
||||
variablen: [
|
||||
{ schluessel: "upn", beschriftung: "UPN" },
|
||||
{ schluessel: "upn", beschriftung: "Nochmal UPN" },
|
||||
],
|
||||
abschnitte: [],
|
||||
}),
|
||||
).rejects.toThrow(/„upn“ gibt es zweimal/);
|
||||
});
|
||||
|
||||
it("nennt bei einem Fehler im Aufbau den Pfad", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
|
||||
await expect(
|
||||
entwurfSpeichern(version.id, {
|
||||
variablen: [],
|
||||
abschnitte: [{ titel: "Abschnitt", schritte: [{ titel: "gut" }, { titel: " " }] }],
|
||||
}),
|
||||
).rejects.toThrow(/abschnitte\.0\.schritte\.1\.titel/);
|
||||
});
|
||||
|
||||
it("hinterlässt bei einem Fehler keinen halben Entwurf", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
|
||||
await entwurfSpeichern(version.id, STRUKTUR);
|
||||
|
||||
await expect(
|
||||
entwurfSpeichern(version.id, {
|
||||
variablen: [],
|
||||
abschnitte: [{ titel: "", schritte: [] }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Der alte Aufbau steht unveraendert.
|
||||
const gelesen = await strukturLesen(version.id);
|
||||
expect(gelesen.abschnitte).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unveränderlichkeit", () => {
|
||||
it("lässt eine veröffentlichte Version nicht mehr bearbeiten", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
|
||||
await expect(entwurfSpeichern(versionId, { variablen: [], abschnitte: [] })).rejects.toThrow(
|
||||
/unveränderlich/,
|
||||
);
|
||||
});
|
||||
|
||||
it("lässt eine veröffentlichte Version nicht erneut veröffentlichen", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
await expect(versionVeroeffentlichen(versionId, null, benutzer.id)).rejects.toThrow(/unveränderlich/);
|
||||
});
|
||||
|
||||
it("lässt eine veröffentlichte Version nicht verwerfen", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
await expect(entwurfVerwerfen(versionId, benutzer.id)).rejects.toThrow(/unveränderlich/);
|
||||
});
|
||||
|
||||
it("prüft den Zustand an einer Stelle", () => {
|
||||
expect(() => versionMussEntwurfSein({ status: "ENTWURF", version: 1 })).not.toThrow();
|
||||
expect(() => versionMussEntwurfSein({ status: "VEROEFFENTLICHT", version: 3 })).toThrow(/Version 3/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bearbeiten erzeugt eine tiefe Kopie", () => {
|
||||
it("kopiert Variablen, Abschnitte und Schritte, statt sie zu teilen", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
expect(entwurf.version).toBe(2);
|
||||
expect(entwurf.status).toBe("ENTWURF");
|
||||
|
||||
const veroeffentlicht = await strukturLesen(versionId);
|
||||
const kopie = await strukturLesen(entwurf.id);
|
||||
|
||||
// Gleicher Inhalt …
|
||||
expect(kopie.variablen.map((v) => v.schluessel)).toEqual(
|
||||
veroeffentlicht.variablen.map((v) => v.schluessel),
|
||||
);
|
||||
expect(kopie.abschnitte.map((a) => a.titel)).toEqual(veroeffentlicht.abschnitte.map((a) => a.titel));
|
||||
// … aber eigene Zeilen.
|
||||
const alteIds = veroeffentlicht.abschnitte.map((a) => a.id);
|
||||
for (const abschnitt of kopie.abschnitte) {
|
||||
expect(alteIds).not.toContain(abschnitt.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("lässt die veröffentlichte Version unberührt, wenn der Entwurf geändert wird", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
|
||||
await entwurfSpeichern(entwurf.id, {
|
||||
variablen: [],
|
||||
abschnitte: [{ titel: "Komplett anders", schritte: [{ titel: "Neu" }] }],
|
||||
});
|
||||
|
||||
const veroeffentlicht = await strukturLesen(versionId);
|
||||
expect(veroeffentlicht.abschnitte.map((a) => a.titel)).toEqual(["Vorbereitung", "Installation"]);
|
||||
expect(veroeffentlicht.variablen).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("kopiert auch die konfig als Wert, nicht als geteilten Verweis", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
|
||||
const kopie = await strukturLesen(entwurf.id);
|
||||
const befehlsschritt = kopie.abschnitte[1]?.schritte[0];
|
||||
expect(befehlsschritt?.konfig).toMatchObject({ shell: "powershell" });
|
||||
|
||||
await testPrisma.vorlagenSchritt.update({
|
||||
where: { id: befehlsschritt!.id },
|
||||
data: { konfig: { shell: "bash", befehl: "echo neu" } },
|
||||
});
|
||||
|
||||
const original = await strukturLesen(versionId);
|
||||
expect(original.abschnitte[1]?.schritte[0]?.konfig).toMatchObject({ shell: "powershell" });
|
||||
});
|
||||
|
||||
it("gibt einen vorhandenen Entwurf zurück, statt einen zweiten anzulegen", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
|
||||
const erst = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
const nochmal = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
|
||||
expect(nochmal.id).toBe(erst.id);
|
||||
expect(
|
||||
await testPrisma.vorlagenVersion.count({
|
||||
where: { vorlageId: vorlage.id, status: "ENTWURF" },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("lässt eine archivierte Vorlage nicht bearbeiten", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
await vorlageArchivieren(vorlage.id, true, benutzer.id);
|
||||
|
||||
await expect(entwurfHolenOderErzeugen(vorlage.id, benutzer.id)).rejects.toThrow(/archiviert/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Veröffentlichen", () => {
|
||||
it("setzt Status, Zeitpunkt, Änderungsnotiz und Urheber", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
|
||||
await entwurfSpeichern(version.id, STRUKTUR);
|
||||
|
||||
const veroeffentlicht = await versionVeroeffentlichen(
|
||||
version.id,
|
||||
" Secure Boot ergänzt ",
|
||||
benutzer.id,
|
||||
);
|
||||
|
||||
expect(veroeffentlicht.status).toBe("VEROEFFENTLICHT");
|
||||
expect(veroeffentlicht.veroeffentlichtAm).not.toBeNull();
|
||||
expect(veroeffentlicht.veroeffentlichtVonId).toBe(benutzer.id);
|
||||
expect(veroeffentlicht.aenderungsnotiz).toBe("Secure Boot ergänzt");
|
||||
});
|
||||
|
||||
it("hält das Veröffentlichen im Protokoll fest", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
|
||||
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||||
where: { entitaetId: versionId, aktion: "Version veröffentlicht" },
|
||||
});
|
||||
expect(eintrag.benutzerId).toBe(benutzer.id);
|
||||
expect(eintrag.nachher).toMatchObject({ status: "VEROEFFENTLICHT" });
|
||||
});
|
||||
|
||||
it("weist eine Vorlage ohne Abschnitte ab", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { version } = await vorlageAnlegen({ name: "Leer" }, benutzer.id);
|
||||
await expect(versionVeroeffentlichen(version.id, null, benutzer.id)).rejects.toThrow(/ohne Abschnitte/);
|
||||
});
|
||||
|
||||
it("verändert bestehende Läufe nicht", async () => {
|
||||
// Das ist der Grund für die ganze Versionierung.
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const kunde = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "PC-001 neu aufsetzen",
|
||||
kundeId: kunde.id,
|
||||
erstelltVonId: benutzer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Zweite Version mit ganz anderem Inhalt veroeffentlichen.
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
await entwurfSpeichern(entwurf.id, {
|
||||
variablen: [],
|
||||
abschnitte: [{ titel: "Alles neu", schritte: [{ titel: "Ein einziger Schritt" }] }],
|
||||
});
|
||||
await versionVeroeffentlichen(entwurf.id, "Umgebaut", benutzer.id);
|
||||
|
||||
const frisch = await testPrisma.lauf.findUniqueOrThrow({
|
||||
where: { id: lauf.id },
|
||||
include: {
|
||||
vorlagenVersion: {
|
||||
include: { abschnitte: { orderBy: { position: "asc" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(frisch.vorlagenVersionId).toBe(versionId);
|
||||
expect(frisch.vorlagenVersion.version).toBe(1);
|
||||
expect(frisch.vorlagenVersion.abschnitte.map((a) => a.titel)).toEqual(["Vorbereitung", "Installation"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Höchstens ein Entwurf", () => {
|
||||
it("lehnt einen zweiten Entwurf auch an der Datenbank ab", async () => {
|
||||
// Die Regel steht zusaetzlich als partieller Index in der Datenbank – der
|
||||
// Dienst prueft nur fuer die bessere Meldung.
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
|
||||
await expect(
|
||||
testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 99, status: "ENTWURF" },
|
||||
}),
|
||||
).rejects.toThrow(/VorlagenVersion_ein_entwurf/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Entwurf verwerfen", () => {
|
||||
it("entfernt den Entwurf samt Aufbau", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
|
||||
await entwurfVerwerfen(entwurf.id, benutzer.id);
|
||||
|
||||
expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId: vorlage.id } })).toBe(1);
|
||||
// Die veroeffentlichte Version behaelt ihren Aufbau.
|
||||
expect(await testPrisma.vorlagenAbschnitt.count()).toBe(2);
|
||||
});
|
||||
|
||||
it("verwirft nicht die einzige Version", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
|
||||
await expect(entwurfVerwerfen(version.id, benutzer.id)).rejects.toThrow(/einzige Version/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Duplizieren", () => {
|
||||
it("legt eine eigenständige Kopie als Entwurf an", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
|
||||
const kopie = await vorlageDuplizieren(vorlage.id, "PC Neuinstallation (Außendienst)", benutzer.id);
|
||||
|
||||
expect(kopie.id).not.toBe(vorlage.id);
|
||||
expect(kopie.name).toBe("PC Neuinstallation (Außendienst)");
|
||||
expect(kopie.kategorie).toBe("Client");
|
||||
|
||||
const versionen = await testPrisma.vorlagenVersion.findMany({ where: { vorlageId: kopie.id } });
|
||||
expect(versionen).toHaveLength(1);
|
||||
expect(versionen[0]?.status).toBe("ENTWURF");
|
||||
expect(versionen[0]?.version).toBe(1);
|
||||
|
||||
const aufbau = await strukturLesen(versionen[0]!.id);
|
||||
expect(aufbau.abschnitte.map((a) => a.titel)).toEqual(["Vorbereitung", "Installation"]);
|
||||
expect(aufbau.variablen).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("lässt eine Änderung an der Kopie das Original unberührt", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const kopie = await vorlageDuplizieren(vorlage.id, "Kopie", benutzer.id);
|
||||
const kopieVersion = await testPrisma.vorlagenVersion.findFirstOrThrow({
|
||||
where: { vorlageId: kopie.id },
|
||||
});
|
||||
|
||||
await entwurfSpeichern(kopieVersion.id, { variablen: [], abschnitte: [{ titel: "X", schritte: [] }] });
|
||||
|
||||
const original = await strukturLesen(versionId);
|
||||
expect(original.abschnitte).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("weist eine Vorlage ohne veröffentlichte Version ab", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await vorlageAnlegen({ name: "Nur Entwurf" }, benutzer.id);
|
||||
await expect(vorlageDuplizieren(vorlage.id, "Kopie", benutzer.id)).rejects.toThrow(
|
||||
/keine veröffentlichte Version/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Archivieren statt Löschen", () => {
|
||||
it("blendet die Vorlage aus, behält aber ihre Läufe", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const kunde = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Ein Lauf",
|
||||
kundeId: kunde.id,
|
||||
erstelltVonId: benutzer.id,
|
||||
},
|
||||
});
|
||||
|
||||
await vorlageArchivieren(vorlage.id, true, benutzer.id);
|
||||
|
||||
const frisch = await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } });
|
||||
expect(frisch.archiviert).toBe(true);
|
||||
expect(await testPrisma.lauf.count()).toBe(1);
|
||||
expect(await testPrisma.protokoll.count({ where: { aktion: "Vorlage archiviert" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("schreibt nichts, wenn sich nichts ändert", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const vorher = await testPrisma.protokoll.count();
|
||||
await vorlageArchivieren(vorlage.id, false, benutzer.id);
|
||||
expect(await testPrisma.protokoll.count()).toBe(vorher);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Läufe hängen nur an veröffentlichten Versionen", () => {
|
||||
it("lässt eine veröffentlichte Version zu", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
await expect(veroeffentlichteVersionPruefen(versionId)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("weist einen Entwurf ab", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
await expect(veroeffentlichteVersionPruefen(entwurf.id)).rejects.toThrow(/noch ein Entwurf/);
|
||||
});
|
||||
|
||||
it("weist eine archivierte Vorlage ab", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
await vorlageArchivieren(vorlage.id, true, benutzer.id);
|
||||
await expect(veroeffentlichteVersionPruefen(versionId)).rejects.toThrow(/startet keine Läufe/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gleichzeitigkeit", () => {
|
||||
it("gibt bei zwei gleichzeitigen Bearbeitungen denselben Entwurf zurück", async () => {
|
||||
// Ohne Wiederholung lief der zweite Aufruf in den eindeutigen Index und der
|
||||
// Benutzer sah „Der Entwurf konnte nicht geöffnet werden“ – obwohl er in
|
||||
// derselben Sekunde angelegt wurde.
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
|
||||
const ergebnisse = await Promise.allSettled([
|
||||
entwurfHolenOderErzeugen(vorlage.id, benutzer.id),
|
||||
entwurfHolenOderErzeugen(vorlage.id, benutzer.id),
|
||||
]);
|
||||
|
||||
const erfuellt = ergebnisse.filter((e) => e.status === "fulfilled");
|
||||
expect(erfuellt).toHaveLength(2);
|
||||
const ids = new Set(erfuellt.map((e) => e.value.id));
|
||||
expect(ids.size).toBe(1);
|
||||
expect(
|
||||
await testPrisma.vorlagenVersion.count({
|
||||
where: { vorlageId: vorlage.id, status: "ENTWURF" },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("lässt Speichern nicht in eine gerade veröffentlichte Version schreiben", async () => {
|
||||
// Der wichtigste Test dieses Meilensteins: ohne Zeilensperre meldeten beide
|
||||
// Aufrufe Erfolg, und die veröffentlichte Version trug hinterher Inhalt,
|
||||
// der nach dem Veröffentlichen geschrieben wurde.
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
|
||||
await Promise.allSettled([
|
||||
versionVeroeffentlichen(entwurf.id, "Veröffentlicht", benutzer.id),
|
||||
entwurfSpeichern(entwurf.id, {
|
||||
variablen: [],
|
||||
abschnitte: [{ titel: "NACHTRAEGLICH", schritte: [] }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const danach = await strukturLesen(entwurf.id);
|
||||
if (danach.status === "VEROEFFENTLICHT") {
|
||||
// Wurde veroeffentlicht, darf der nachträgliche Aufbau nicht drinstehen.
|
||||
expect(danach.abschnitte.map((a) => a.titel)).not.toContain("NACHTRAEGLICH");
|
||||
}
|
||||
});
|
||||
|
||||
it("veröffentlicht bei zwei gleichzeitigen Aufrufen nur einmal", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
await entwurfSpeichern(entwurf.id, STRUKTUR);
|
||||
|
||||
const ergebnisse = await Promise.allSettled([
|
||||
versionVeroeffentlichen(entwurf.id, "A", benutzer.id),
|
||||
versionVeroeffentlichen(entwurf.id, "B", benutzer.id),
|
||||
]);
|
||||
|
||||
expect(ergebnisse.filter((e) => e.status === "fulfilled")).toHaveLength(1);
|
||||
expect(
|
||||
await testPrisma.protokoll.count({
|
||||
where: { entitaetId: entwurf.id, aktion: "Version veröffentlicht" },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zwei Redakteure am selben Entwurf", () => {
|
||||
it("weist ein Speichern auf einem überholten Stand ab", async () => {
|
||||
// Ohne diese Prüfung überschriebe der zweite die Arbeit des ersten
|
||||
// spurlos – und weil Entwürfe bewusst nicht protokolliert werden, wäre sie
|
||||
// auch nicht wiederherstellbar.
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
|
||||
const geladen = await testPrisma.vorlagenVersion.findUniqueOrThrow({
|
||||
where: { id: version.id },
|
||||
});
|
||||
|
||||
// Der erste speichert.
|
||||
const { stand } = await entwurfSpeichern(version.id, STRUKTUR, geladen.geaendertAm);
|
||||
|
||||
// Der zweite hat noch den alten Stand in der Hand.
|
||||
await expect(
|
||||
entwurfSpeichern(
|
||||
version.id,
|
||||
{ variablen: [], abschnitte: [{ titel: "Meine Fassung", schritte: [] }] },
|
||||
geladen.geaendertAm,
|
||||
),
|
||||
).rejects.toThrow(/zwischenzeitlich/);
|
||||
|
||||
// Mit dem frischen Stand geht es.
|
||||
await expect(
|
||||
entwurfSpeichern(
|
||||
version.id,
|
||||
{ variablen: [], abschnitte: [{ titel: "Meine Fassung", schritte: [] }] },
|
||||
stand,
|
||||
),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("speichert ohne mitgegebenen Stand weiterhin", async () => {
|
||||
// Ein Aufrufer, der den Stand nicht kennt (Import, Seed), soll nicht
|
||||
// blockiert sein.
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
|
||||
await expect(entwurfSpeichern(version.id, STRUKTUR)).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Die Datenbank schützt die Unveränderlichkeit mit", () => {
|
||||
it("lässt einen Schritt einer veröffentlichten Version nicht ändern", async () => {
|
||||
// Bisher hing die Regel allein am Dienst. Migrationen, psql und ab Phase 4
|
||||
// die Connectoren schreiben daran vorbei.
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const gelesen = await strukturLesen(versionId);
|
||||
const schritt = gelesen.abschnitte[0]?.schritte[0];
|
||||
|
||||
await expect(
|
||||
testPrisma.vorlagenSchritt.update({
|
||||
where: { id: schritt!.id },
|
||||
data: { titel: "Heimlich geändert" },
|
||||
}),
|
||||
).rejects.toThrow(/unveraenderlich/i);
|
||||
});
|
||||
|
||||
it("lässt Abschnitte einer veröffentlichten Version nicht löschen", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
await expect(testPrisma.vorlagenAbschnitt.deleteMany({ where: { versionId } })).rejects.toThrow(
|
||||
/unveraenderlich/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("lässt keine Variable an eine veröffentlichte Version hängen", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
await expect(
|
||||
testPrisma.vorlagenVariable.create({
|
||||
data: { versionId, schluessel: "heimlich", beschriftung: "Heimlich" },
|
||||
}),
|
||||
).rejects.toThrow(/unveraenderlich/i);
|
||||
});
|
||||
|
||||
it("lässt eine veröffentlichte Version nicht zum Entwurf zurückdrehen", async () => {
|
||||
// Das wäre die Hintertür, durch die sich alles Obige aushebeln ließe.
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
await expect(
|
||||
testPrisma.vorlagenVersion.update({
|
||||
where: { id: versionId },
|
||||
data: { status: "ENTWURF" },
|
||||
}),
|
||||
).rejects.toThrow(/nicht wieder zum Entwurf/i);
|
||||
});
|
||||
|
||||
it("lässt einen Entwurf weiterhin frei bearbeiten", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
const gelesen = await strukturLesen(entwurf.id);
|
||||
await expect(
|
||||
testPrisma.vorlagenAbschnitt.update({
|
||||
where: { id: gelesen.abschnitte[0]!.id },
|
||||
data: { titel: "Umbenannt" },
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kopfdaten bearbeiten", () => {
|
||||
it("ändert Name, Kategorie und Schlagworte und protokolliert es", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
|
||||
await vorlageBearbeiten(
|
||||
vorlage.id,
|
||||
{ name: "PC Neuinstallation (Standard)", kategorie: "Client", schlagworte: ["windows"] },
|
||||
benutzer.id,
|
||||
);
|
||||
|
||||
const frisch = await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } });
|
||||
expect(frisch.name).toBe("PC Neuinstallation (Standard)");
|
||||
expect(frisch.schlagworte).toEqual(["windows"]);
|
||||
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||||
where: { entitaetId: vorlage.id, aktion: { contains: "geändert" } },
|
||||
});
|
||||
expect(eintrag.aktion).toContain("Name");
|
||||
});
|
||||
|
||||
it("lässt die Versionen unberührt", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage, versionId } = await veroeffentlichteVorlage(benutzer.id);
|
||||
await vorlageBearbeiten(vorlage.id, { name: "Anders" }, benutzer.id);
|
||||
const gelesen = await strukturLesen(versionId);
|
||||
expect(gelesen.abschnitte).toHaveLength(2);
|
||||
expect(gelesen.status).toBe("VEROEFFENTLICHT");
|
||||
});
|
||||
|
||||
it("schreibt nichts, wenn sich nichts ändert", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const vorher = await testPrisma.protokoll.count();
|
||||
await vorlageBearbeiten(vorlage.id, { name: "PC Neuinstallation" }, benutzer.id);
|
||||
expect(await testPrisma.protokoll.count()).toBe(vorher);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Archivierte Vorlage", () => {
|
||||
it("lässt sich nicht veröffentlichen", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
|
||||
await vorlageArchivieren(vorlage.id, true, benutzer.id);
|
||||
|
||||
await expect(versionVeroeffentlichen(entwurf.id, null, benutzer.id)).rejects.toThrow(/archiviert/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user