feat(vorlagen): bedingte Abschnitte und Schritte (#039)
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions

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>
This commit is contained in:
Florian Wilde
2026-10-23 14:54:10 +02:00
co-authored by Claude Opus 5
parent 2f247b3617
commit a2330ef682
29 changed files with 3051 additions and 146 deletions
+721
View File
@@ -0,0 +1,721 @@
import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it } from "vitest";
import { SchrittZustand, VariablenTyp, VersionStatus } from "@/generated/prisma/enums";
import { laufZumDrucken } from "@/server/queries/druck";
import { laufZumAbarbeiten } from "@/server/queries/laeufe";
import { LaufFehler, laufAbschliessen, laufAnlegen } from "@/server/services/laeufe";
import { versionMitAufbau } from "@/server/queries/vorlagen";
import {
entwurfHolenOderErzeugen,
entwurfSpeichern,
versionVeroeffentlichen,
vorlageAnlegen,
vorlageDuplizieren,
} from "@/server/services/vorlagen";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Bedingte Abschnitte und Schritte gegen die Datenbank (#039).
*
* Die reine Auswertung steht in `tests/bedingungen.test.ts`. Hier geht es um die
* Frage, die eine reine Funktion nicht beantworten kann: **greift sie überall?**
*
* Drei Schichten brauchen sie — der Dienst beim Abschluss, das Lesemodell der
* Abarbeitung und das der Druckansicht. Vergisst eine davon die Auswertung,
* sieht der Techniker andere Schritte als der Ausdruck, oder ein unsichtbarer
* Pflichtschritt hält den Abschluss auf. Beides fällt in einer Suite nicht auf,
* die nur die Logik prüft.
*/
type AbschnittsVorgabe = {
readonly titel: string;
readonly bedingung?: unknown;
readonly schritte: readonly {
readonly titel: string;
readonly pflicht?: boolean;
readonly bedingung?: unknown;
}[];
};
/**
* Legt eine Vorlage an und veröffentlicht sie.
*
* `nachtragen` wird mit den Schritt-Kennungen gerufen, **bevor** veröffentlicht
* wird — genau wie im Editor: eine Bedingung auf einem Schritt braucht dessen
* Kennung, und eine veröffentlichte Version ist unveränderlich (#009). Der
* Trigger `…_bedingung_gleiche_version` verlangt zusätzlich, dass der Schritt
* zur selben Version gehört.
*/
async function aufbau(
abschnitte: readonly AbschnittsVorgabe[],
nachtragen?: (schrittIds: ReadonlyMap<string, string>, versionId: string) => Promise<void>,
) {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
await testPrisma.vorlagenVariable.create({
data: {
versionId: version.id,
schluessel: "konto_typ",
beschriftung: "Kontotyp",
typ: VariablenTyp.AUSWAHL,
optionen: ["AD", "ENTRA"],
position: 0,
},
});
const schrittIds = new Map<string, string>();
for (const [position, abschnitt] of abschnitte.entries()) {
const angelegt = await testPrisma.vorlagenAbschnitt.create({
data: {
versionId: version.id,
titel: abschnitt.titel,
position,
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung as never }),
},
});
for (const [stelle, schritt] of abschnitt.schritte.entries()) {
const angelegterSchritt = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: angelegt.id,
titel: schritt.titel,
pflicht: schritt.pflicht ?? false,
position: stelle,
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung as never }),
},
});
schrittIds.set(schritt.titel, angelegterSchritt.id);
}
}
await nachtragen?.(schrittIds, version.id);
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return {
kunde,
admin: { id: admin.id, rolle: admin.rolle },
versionId: version.id,
schrittIds,
};
}
/** Der echte Fall aus dem Seed: beide Konto-Abschnitte, eine Variable entscheidet. */
const BEIDE_KONTEN: readonly AbschnittsVorgabe[] = [
{ titel: "Vorbereitung", schritte: [{ titel: "Papiere", pflicht: true }] },
{
titel: "Konto anlegen (Active Directory)",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
schritte: [
{ titel: "New-ADUser", pflicht: true },
{ titel: "Gruppen setzen", pflicht: true },
],
},
{
titel: "Konto anlegen (Entra ID)",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "ENTRA" },
schritte: [{ titel: "New-MgUser", pflicht: true }],
},
];
const titel = (abschnitte: readonly { readonly titel: string }[]): readonly string[] =>
abschnitte.map((abschnitt) => abschnitt.titel);
describe.runIf(datenbankVerfuegbar)("Ein Lauf mit bedingten Abschnitten", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("zeigt bei konto_typ = AD nur den AD-Abschnitt", async () => {
/*
* **Der Fall, für den dieser Meilenstein da ist.** Die Onboarding-Vorlage im
* Seed trägt beide Konto-Abschnitte, und `konto_typ` sagt längst, welcher
* gilt — bis hierher wertete das niemand aus, und ein Techniker klickte neun
* Schritte weg.
*/
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin);
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
expect(titel(daten!.abschnitte)).toEqual(["Vorbereitung", "Konto anlegen (Active Directory)"]);
});
it("zeigt bei konto_typ = ENTRA nur den Entra-Abschnitt", async () => {
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen(
{ vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } },
admin,
);
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
expect(titel(daten!.abschnitte)).toEqual(["Vorbereitung", "Konto anlegen (Entra ID)"]);
});
it("zeigt bei nicht gesetzter Variable beide – und blendet nichts aus", async () => {
/*
* Eine Bedingung, die sich nicht entscheiden lässt, blendet nichts aus. Das
* ist genau das Verhalten von **vor** diesem Meilenstein, für jeden Lauf —
* eine Verschlechterung ist es also nicht. Eine erfundene Antwort wäre eine.
*/
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
expect(titel(daten!.abschnitte)).toEqual([
"Vorbereitung",
"Konto anlegen (Active Directory)",
"Konto anlegen (Entra ID)",
]);
});
it("nummeriert die sichtbaren Abschnitte lückenlos", async () => {
/*
* `01 · Vorbereitung` zählt, was der Techniker **sieht**. Ein ausgeblendeter
* Abschnitt darf keine Lücke hinterlassen — die Nummer kommt aus der
* Position und wird nie gespeichert (CLAUDE.md, „Gestaltung“).
*/
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen(
{ vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } },
admin,
);
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
expect(daten!.abschnitte.map((abschnitt) => abschnitt.nummer)).toEqual(["01", "02"]);
});
it("hält den Abschluss nicht an einem ausgeblendeten Pflichtschritt auf", async () => {
/*
* **Die zweite Kernzusage.** Der Entra-Abschnitt ist ausgeblendet, sein
* Schritt ist Pflicht. Blockierte er, wäre der Lauf unabschließbar: abhaken
* lässt sich nur, was in der Abarbeitung steht.
*/
const { admin, versionId, schrittIds } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin);
for (const name of ["Papiere", "New-ADUser", "Gruppen setzen"]) {
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schrittIds.get(name)!, zustand: SchrittZustand.ERLEDIGT },
admin,
);
}
await expect(laufAbschliessen(lauf.id, admin)).resolves.toBeDefined();
});
it("zeigt im Druck genau dieselben Abschnitte wie in der Abarbeitung", async () => {
/*
* Ein Ausdruck, der andere Schritte zeigt als der Bildschirm, ist das
* Gegenteil eines Beweismittels. Geprüft wird deshalb gegen die **andere**
* Schicht und nicht gegen eine erwartete Liste.
*/
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin);
const abarbeitung = await laufZumAbarbeiten(lauf.id, admin, "Tester");
const druck = await laufZumDrucken(lauf.id, admin);
expect(titel(druck!.abschnitte)).toEqual(titel(abarbeitung!.abschnitte));
});
});
describe.runIf(datenbankVerfuegbar)("Eine Bedingung auf einem Schrittergebnis", () => {
beforeEach(async () => {
await datenbankLeeren();
});
/**
* Setzt am Abschnitt „Nacharbeit“ die Bedingung „Prüfen ist fehlgeschlagen“.
*
* Als Nachtrag im **Entwurf**, weil die Bedingung die Kennung des Schritts
* braucht und der erst beim Anlegen entsteht. Danach wird veröffentlicht, und
* danach ändert sich an der Version nichts mehr (#009).
*/
const nacharbeitWennFehlgeschlagen = async (
schrittIds: ReadonlyMap<string, string>,
versionId: string,
): Promise<void> => {
await testPrisma.vorlagenAbschnitt.updateMany({
where: { versionId, titel: "Nacharbeit" },
data: {
bedingung: {
art: "schritt",
schrittId: schrittIds.get("Prüfen")!,
zustaende: ["FEHLGESCHLAGEN"],
},
},
});
};
it("erscheint erst, wenn die Bedingung während des Laufs umschlägt", async () => {
/*
* **Der Grund, warum laufend ausgewertet wird und nicht beim Start.** Die
* Antwort auf „ist Prüfen fehlgeschlagen?“ entsteht erst während der Arbeit;
* festgeschrieben wie bei #031 wäre sie beim Start immer „nein“.
*/
// Erst anlegen, um die Schritt-Kennung zu bekommen, dann die Bedingung setzen:
// der Trigger verlangt einen Schritt derselben Version.
const { admin, versionId, schrittIds } = await aufbau(
[
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
{ titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen", pflicht: true }] },
],
nacharbeitWennFehlgeschlagen,
);
const pruefenId = schrittIds.get("Prüfen")!;
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
// Zu Beginn ist „Prüfen“ OFFEN. Das ist eine **Antwort**, nicht eine Lücke:
// der Abschnitt gilt nicht.
expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual(["Prüfung"]);
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: pruefenId,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Sicherung nicht lesbar",
},
admin,
);
expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual([
"Prüfung",
"Nacharbeit",
]);
});
it("lässt ein Schrittergebnis mit Notiz stehen, wenn seine Bedingung umschlägt", async () => {
/*
* Ausgeblendet heißt **nicht** gelöscht. Jemand hat eine Notiz geschrieben;
* sie zu verlieren, weil eine Bedingung kippt, wäre Datenverlust durch eine
* Anzeigeregel. Schlägt die Bedingung zurück, ist alles wieder da.
*/
const { admin, versionId, schrittIds } = await aufbau(
[
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
{ titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen" }] },
],
nacharbeitWennFehlgeschlagen,
);
const pruefenId = schrittIds.get("Prüfen")!;
const ticketId = schrittIds.get("Ticket aufmachen")!;
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "x" },
admin,
);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: ticketId, zustand: SchrittZustand.ERLEDIGT, notiz: "TIC-4711" },
admin,
);
// Bedingung kippt: der Abschnitt verschwindet aus der Ansicht …
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.ERLEDIGT },
admin,
);
expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual(["Prüfung"]);
// … die Zeile bleibt aber in der Datenbank.
const zeile = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: ticketId },
});
expect(zeile.notiz).toBe("TIC-4711");
// Und schlägt sie zurück, ist alles wieder da.
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "y" },
admin,
);
const wieder = await laufZumAbarbeiten(lauf.id, admin, "T");
expect(titel(wieder!.abschnitte)).toEqual(["Prüfung", "Nacharbeit"]);
expect(wieder!.abschnitte[1]?.schritte[0]?.notiz).toBe("TIC-4711");
});
it("hält den Abschluss auf, solange ein sichtbarer Pflichtschritt offen ist", async () => {
// Die Gegenrichtung, und sie ist nötig: eine Abschlussregel, die **immer**
// durchlässt, sähe in den Tests oben genauso aus.
const { admin, versionId, schrittIds } = await aufbau(
[
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
{ titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen", pflicht: true }] },
],
nacharbeitWennFehlgeschlagen,
);
const pruefenId = schrittIds.get("Prüfen")!;
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "x" },
admin,
);
// „Ticket aufmachen“ ist jetzt sichtbar und Pflicht – der Abschluss wartet.
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(LaufFehler);
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/Ticket aufmachen/u);
});
});
describe.runIf(datenbankVerfuegbar)("Eine Bedingung an einem einzelnen Schritt", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("blendet den Schritt aus, nicht den ganzen Abschnitt", async () => {
const { admin, versionId } = await aufbau([
{
titel: "Konto",
schritte: [
{ titel: "Immer" },
{
titel: "Nur bei AD",
pflicht: true,
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
},
],
},
]);
const lauf = await laufAnlegen(
{ vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } },
admin,
);
const daten = await laufZumAbarbeiten(lauf.id, admin, "T");
expect(titel(daten!.abschnitte)).toEqual(["Konto"]);
expect(daten!.abschnitte[0]?.schritte.map((schritt) => schritt.titel)).toEqual(["Immer"]);
// Und der ausgeblendete Pflichtschritt hält den Abschluss nicht auf.
expect(daten!.anzahlPflichtOffen).toBe(0);
});
});
describe.runIf(datenbankVerfuegbar)("Der Weg durch den Editor", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("übersteht Speichern, Neuladen und Veröffentlichen", async () => {
/*
* **Der Weg, auf dem eine Bedingung verloren gehen würde.**
* `entwurfSpeichern` löscht den Entwurf und schreibt ihn neu — die Schritte
* bekommen dabei neue Kennungen. Eine Bedingung, die auf eine Kennung zeigt,
* überlebt das nicht; in der Struktur steht deshalb ein **Titel-Pfad**, so
* wie in einer Austauschdatei, und derselbe Code löst beide auf.
*
* Dreimal gespeichert, weil einmal nichts beweist: der Pfad muss **jedes**
* Neuschreiben überstehen.
*/
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const redakteur = await testPrisma.benutzer.create({
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
});
const handelnder = { id: redakteur.id, rolle: redakteur.rolle };
const { vorlage } = await vorlageAnlegen({ name: "Mit Bedingung" }, redakteur.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id);
const struktur = {
variablen: [
{ schluessel: "konto_typ", beschriftung: "Kontotyp", typ: "AUSWAHL", optionen: ["AD", "ENTRA"] },
],
abschnitte: [
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
{
titel: "Nacharbeit",
bedingung: {
art: "schritt",
abschnitt: "Prüfung",
schritt: "Prüfen",
zustaende: ["FEHLGESCHLAGEN"],
},
schritte: [
{
titel: "Nur bei AD",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
},
],
},
],
} as const;
for (let runde = 1; runde <= 3; runde += 1) {
await entwurfSpeichern(entwurf.id, struktur as never);
// Zurückgelesen steht wieder der Pfad da, nicht die Kennung.
const gelesen = await versionMitAufbau(entwurf.id, handelnder);
expect(gelesen!.struktur.abschnitte[1]?.bedingung, `Runde ${runde}`).toEqual({
art: "schritt",
abschnitt: "Prüfung",
schritt: "Prüfen",
zustaende: ["FEHLGESCHLAGEN"],
});
expect(gelesen!.struktur.abschnitte[1]?.schritte[0]?.bedingung).toEqual({
art: "variable",
schluessel: "konto_typ",
operator: "ist",
wert: "AD",
});
// Und in der Datenbank steht die **aktuelle** Kennung.
const zeilen = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId: entwurf.id },
orderBy: { position: "asc" },
include: { schritte: true },
});
expect(zeilen[1]?.bedingung).toMatchObject({
art: "schritt",
schrittId: zeilen[0]!.schritte[0]!.id,
});
}
// Veröffentlichen lässt die Bedingung stehen – und ein Lauf wertet sie aus.
await versionVeroeffentlichen(entwurf.id, null, redakteur.id);
const lauf = await laufAnlegen(
{ vorlagenVersionId: entwurf.id, kundeId: kunde.id, variablen: { konto_typ: "AD" } },
handelnder,
);
expect(titel((await laufZumAbarbeiten(lauf.id, handelnder, "T"))!.abschnitte)).toEqual(["Prüfung"]);
});
it("lässt eine Bedingung weg, deren Titel-Pfad es nicht gibt", async () => {
/*
* Ein Abschnitt, dessen Zielschritt gelöscht wurde: beim nächsten Speichern
* findet der Pfad nichts. Weggelassen heißt „gilt immer“ — sichtbar in der
* Oberfläche. Eine Bedingung mit einem Verweis ins Leere wäre dasselbe
* Ergebnis, nur unerklärlich.
*/
await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const redakteur = await testPrisma.benutzer.create({
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
});
const { vorlage } = await vorlageAnlegen({ name: "Verwaister Verweis" }, redakteur.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id);
await entwurfSpeichern(entwurf.id, {
variablen: [],
abschnitte: [
{ titel: "Eins", schritte: [{ titel: "A" }] },
{
titel: "Zwei",
bedingung: {
art: "schritt",
abschnitt: "Gibt es nicht",
schritt: "Auch nicht",
zustaende: ["ERLEDIGT"],
},
schritte: [{ titel: "B" }],
},
],
} as never);
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId: entwurf.id, titel: "Zwei" },
});
expect(zeile.bedingung).toBeNull();
});
});
describe.runIf(datenbankVerfuegbar)("Die tiefe Kopie", () => {
beforeEach(async () => {
await datenbankLeeren();
});
/**
* Legt eine veröffentlichte Version mit **allen** Struktureigenschaften an.
*
* Absichtlich vollgepackt: der Test darunter vergleicht Kopie und Original
* Feld für Feld, und was hier fehlt, prüft er nicht.
*/
async function reicheVersion() {
await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const redakteur = await testPrisma.benutzer.create({
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
});
const { vorlage } = await vorlageAnlegen({ name: "Vollgepackt" }, redakteur.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id);
await entwurfSpeichern(entwurf.id, {
variablen: [
{ schluessel: "konto_typ", beschriftung: "Kontotyp", typ: "AUSWAHL", optionen: ["AD", "ENTRA"] },
],
abschnitte: [
{
titel: "Prüfung",
nurFuerAbteilungen: ["Buchhaltung"],
schritte: [
{
titel: "Prüfen",
pflicht: true,
hilfetext: "Mit Blick aufs Protokoll.",
dokuUrl: "https://intern.example/pruefen",
typ: "ZAHL",
konfig: { min: 1, max: 9, einheit: "GB" },
},
],
},
{
titel: "Nacharbeit",
nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"],
bedingung: {
art: "schritt",
abschnitt: "Prüfung",
schritt: "Prüfen",
zustaende: ["FEHLGESCHLAGEN"],
},
schritte: [
{
titel: "Nur bei AD",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
},
],
},
],
} as never);
await versionVeroeffentlichen(entwurf.id, "Erste", redakteur.id);
return { vorlageId: vorlage.id, versionId: entwurf.id, redakteurId: redakteur.id };
}
/** Die Struktur einer Version, ohne alles, was sich bei einer Kopie ändern darf. */
async function struktur(versionId: string) {
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId },
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
});
// Kennungen wandern bei einer Kopie – verglichen wird der Inhalt.
const ohneKennung = (bedingung: unknown): unknown => {
if (typeof bedingung !== "object" || bedingung === null) return bedingung;
const kopie = { ...(bedingung as Record<string, unknown>) };
if ("schrittId" in kopie) kopie["schrittId"] = "<verweist>";
return kopie;
};
return abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
bedingung: ohneKennung(abschnitt.bedingung),
schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl,
bedingung: ohneKennung(schritt.bedingung),
})),
}));
}
it("ist bei „Bearbeiten“ inhaltsgleich – mit allem", async () => {
/*
* **Der Test, der zwei stille Datenverluste gefunden hätte.** Die tiefe
* Kopie nahm `nurFuerAbteilungen` (seit #031) und `bedingung` (seit #039)
* nicht mit: „Bearbeiten“ verlor alle Abteilungseinschränkungen, die neue
* Version galt für jeden Lauf. Also mehr Schritte statt weniger, und niemand
* merkt es.
*
* Die Ursache war ein von Hand getippter Zwischentyp, der weniger Felder
* nannte als die Abfrage liefert. Gefunden erst bei der Klickprobe am
* gebauten Stand.
*
* Verglichen wird deshalb **Feld für Feld und nicht stichprobenweise**: so
* fällt auch das nächste Feld auf, das jemand am Modell ergänzt und beim
* Kopieren vergisst.
*/
const { vorlageId, versionId, redakteurId } = await reicheVersion();
const vorher = await struktur(versionId);
// „Bearbeiten“ in der Oberfläche ruft `entwurfHolenOderErzeugen`: das ist
// die tiefe Kopie der letzten veröffentlichten Version (#009).
const entwurf = await entwurfHolenOderErzeugen(vorlageId, redakteurId);
expect(await struktur(entwurf.id)).toEqual(vorher);
});
it("ist beim Duplizieren inhaltsgleich – mit allem", async () => {
// Dieselbe Prüfung für den zweiten Weg: er hatte denselben Fehler, weil er
// dieselbe Kopie ausgeschrieben hatte. Jetzt teilen beide eine Stelle.
const { vorlageId, versionId, redakteurId } = await reicheVersion();
const vorher = await struktur(versionId);
const kopie = await vorlageDuplizieren(vorlageId, "Vollgepackt (Kopie)", redakteurId);
const neueVersion = await testPrisma.vorlagenVersion.findFirstOrThrow({
where: { vorlageId: kopie.id },
});
expect(await struktur(neueVersion.id)).toEqual(vorher);
});
it("schreibt die Schritt-Bedingung auf die neue Kennung um", async () => {
/*
* Die Kennungen sind neu; ein Verweis auf die alte wäre nach dem
* Veröffentlichen nicht mehr entscheidbar, und der Abschnitt erschiene
* stillschweigend immer. Der Trigger in der Datenbank verlangt dasselbe.
*/
const { vorlageId, redakteurId } = await reicheVersion();
const entwurf = await entwurfHolenOderErzeugen(vorlageId, redakteurId);
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId: entwurf.id },
orderBy: { position: "asc" },
include: { schritte: true },
});
expect(abschnitte[1]?.bedingung).toMatchObject({
art: "schritt",
// Die Kennung des Schritts der **neuen** Version.
schrittId: abschnitte[0]!.schritte[0]!.id,
});
});
});
describe("Die Auswertung sitzt an jeder Stelle, die filtert", () => {
it("ruft geltendeStruktur überall, wo nurGeltende gerufen wird", () => {
/*
* **Strukturell geprüft, weil eine vergessene Stelle nicht auffällt.** Drei
* Schichten dampfen die Struktur eines Laufs ein: der Dienst beim Abschluss,
* die Abarbeitung und die Druckansicht. Vergisst eine davon die Bedingungen,
* sieht der Techniker andere Schritte als der Ausdruck — oder ein
* unsichtbarer Pflichtschritt hält den Abschluss auf.
*
* Dieselbe Art Test wie „jede Seite prüft selbst“ (#038): die Zusage steht
* nicht in einer Prüfung, sondern in der Vollständigkeit einer Liste, und die
* muss jemand bewachen.
*/
const dateien = [
"src/server/services/laeufe.ts",
"src/server/queries/laeufe.ts",
"src/server/queries/druck.ts",
];
const befunde: string[] = [];
for (const datei of dateien) {
const quelle = readFileSync(datei, "utf8");
if (!quelle.includes("nurGeltende(")) {
befunde.push(`${datei} ruft nurGeltende nicht mehr – ist die Liste noch richtig?`);
continue;
}
if (!quelle.includes("geltendeStruktur(")) {
befunde.push(`${datei} filtert nach #031, wertet aber keine Bedingungen aus (#039).`);
}
}
expect(befunde).toEqual([]);
});
});