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>
722 lines
28 KiB
TypeScript
722 lines
28 KiB
TypeScript
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([]);
|
||
});
|
||
});
|