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

Entscheidungen, alle im Issue begruendet:

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

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

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

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

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

Closes #39

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-23 14:54:10 +02:00

722 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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([]);
});
});