import { beforeEach, describe, expect, it } from "vitest"; import { LaufStatus, SchrittZustand, VersionStatus } from "@/generated/prisma/enums"; import { LaufFehler, laufAnlegen, laufAbschliessen } from "@/server/services/laeufe"; import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte"; import { laufZumAbarbeiten } from "@/server/queries/laeufe"; import { laufZumDrucken } from "@/server/queries/druck"; import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Abteilungsspezifische Zusatzabschnitte (#031). * * Die Auswahl wird **beim Start festgeschrieben**. Alles hier dreht sich darum: * dass sie stimmt, dass sie sich danach nicht mehr aendert, dass ein * ausgefilterter Pflichtschritt den Abschluss nicht blockiert, und dass die * Druckansicht genau dasselbe zeigt wie die Abarbeitung. */ describe("Auswahlregel", () => { const abschnitte = [ { id: "a-alle", nurFuerAbteilungen: [] }, { id: "a-buch", nurFuerAbteilungen: ["Buchhaltung"] }, { id: "a-beide", nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"] }, ]; it("nimmt uneingeschränkte Abschnitte immer", () => { expect(geltendeAbschnitteErmitteln(abschnitte, null)).toEqual(["a-alle"]); }); it("nimmt den Abschnitt der passenden Abteilung dazu", () => { expect(geltendeAbschnitteErmitteln(abschnitte, "Buchhaltung")).toEqual(["a-alle", "a-buch", "a-beide"]); expect(geltendeAbschnitteErmitteln(abschnitte, "Vertrieb")).toEqual(["a-alle", "a-beide"]); }); it("vergleicht ohne Rücksicht auf Groß- und Kleinschreibung und Ränder", () => { // Die Namen kommen aus zwei Quellen – der gepflegten Abteilung und dem // Freitextfeld im Editor. „buchhaltung“ ist dieselbe Abteilung. expect(geltendeAbschnitteErmitteln(abschnitte, " buchhaltung ")).toContain("a-buch"); }); it("lässt eine unbekannte Abteilung nur die uneingeschränkten Abschnitte", () => { expect(geltendeAbschnitteErmitteln(abschnitte, "Einkauf")).toEqual(["a-alle"]); }); }); describe("nurGeltende", () => { const abschnitte = [{ id: "eins" }, { id: "zwei" }, { id: "drei" }]; it("gibt bei leerer Liste alles zurück – so bleiben alte Läufe unberührt", () => { expect(nurGeltende(abschnitte, [])).toEqual(abschnitte); }); it("filtert und behält die Reihenfolge der Version", () => { expect(nurGeltende(abschnitte, ["drei", "eins"])).toEqual([{ id: "eins" }, { id: "drei" }]); }); }); async function aufbau( abschnitte: readonly { readonly titel: string; readonly nurFuerAbteilungen?: readonly string[]; readonly schritte: readonly { readonly titel: string; readonly pflicht?: boolean }[]; }[], ) { 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 buchhaltung = await testPrisma.abteilung.create({ data: { kundeId: kunde.id, name: "Buchhaltung" }, }); const vertrieb = await testPrisma.abteilung.create({ data: { kundeId: kunde.id, name: "Vertrieb" }, }); const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } }); const version = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF }, }); for (const [position, abschnitt] of abschnitte.entries()) { const angelegt = await testPrisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: abschnitt.titel, position, nurFuerAbteilungen: [...(abschnitt.nurFuerAbteilungen ?? [])], }, }); await testPrisma.vorlagenSchritt.createMany({ data: abschnitt.schritte.map((schritt, stelle) => ({ abschnittId: angelegt.id, titel: schritt.titel, pflicht: schritt.pflicht ?? false, position: stelle, })), }); } await testPrisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, }); return { kunde, admin: { id: admin.id, rolle: admin.rolle }, buchhaltung, vertrieb, versionId: version.id, }; } const VORLAGE = [ { titel: "Vorbereitung", schritte: [{ titel: "Papiere", pflicht: true }] }, { titel: "Buchhaltung", nurFuerAbteilungen: ["Buchhaltung"], schritte: [{ titel: "DATEV einrichten", pflicht: true }], }, { titel: "Abschluss", schritte: [{ titel: "Übergabe", pflicht: true }] }, ] as const; describe.runIf(datenbankVerfuegbar)("Abteilungsabschnitte am Lauf", () => { beforeEach(async () => { await datenbankLeeren(); }); it("nimmt den Buchhaltungs-Abschnitt für einen Lauf der Buchhaltung", async () => { const { admin, buchhaltung, versionId } = await aufbau(VORLAGE); const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: buchhaltung.id }, admin); const daten = await laufZumAbarbeiten(lauf.id, admin); expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual([ "Vorbereitung", "Buchhaltung", "Abschluss", ]); // Die Nummerierung zählt nur die geltenden Schritte. expect(daten?.abschnitte.flatMap((a) => a.schritte.map((s) => s.nummer))).toEqual([1, 2, 3]); }); it("lässt ihn für einen Lauf des Vertriebs weg", async () => { const { admin, vertrieb, versionId } = await aufbau(VORLAGE); const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin); const daten = await laufZumAbarbeiten(lauf.id, admin); expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual(["Vorbereitung", "Abschluss"]); expect(daten?.anzahlPflicht).toBe(2); }); it("gibt einem Lauf ohne Abteilung nur die uneingeschränkten Abschnitte", async () => { // Ein Firewall-Rollout hat keine Abteilung, und das ist kein Mangel. const { admin, versionId } = await aufbau(VORLAGE); const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin); const daten = await laufZumAbarbeiten(lauf.id, admin); expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual(["Vorbereitung", "Abschluss"]); }); it("legt nur für die geltenden Schritte ein Schrittergebnis an", async () => { const { admin, vertrieb, versionId } = await aufbau(VORLAGE); const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin); const ergebnisse = await testPrisma.schrittergebnis.findMany({ where: { laufId: lauf.id }, include: { vorlagenSchritt: true }, }); expect(ergebnisse.map((e) => e.vorlagenSchritt.titel).sort()).toEqual(["Papiere", "Übergabe"]); }); it("schreibt im Protokoll die Schritte dieses Laufs, nicht die der Version", async () => { /* * **Die Zahl war die der Version** (Reviewbefund zu #029). Bei einer Vorlage * mit abteilungsgebundenen Abschnitten behauptete der Eintrag „Lauf angelegt“ * mehr Schritte, als der Lauf hat. * * Das Protokoll ist seit #023 ansehbar: eine Zahl darin, die nicht zur Liste * darunter passt, schickt jemanden auf die Suche nach Schritten, die es nie * gab. */ const { admin, vertrieb, versionId } = await aufbau(VORLAGE); const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin); const wirklich = await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id } }); const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { laufId: lauf.id, aktion: "Lauf angelegt" }, }); expect(wirklich).toBe(2); expect(eintrag.nachher).toMatchObject({ anzahlSchritte: wirklich }); }); it("ändert die Abschnitte nicht, wenn später die Abteilung des Laufs wechselt", async () => { /* * Der Grund für das Festschreiben: ein archivierter Lauf muss genau die * Schritte zeigen, die damals abgearbeitet wurden. Ein Abteilungswechsel * darf keinen Schritt entfernen, an dem schon eine Notiz hängt. */ const { admin, buchhaltung, vertrieb, versionId } = await aufbau(VORLAGE); const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: buchhaltung.id }, admin); expect((await laufZumAbarbeiten(lauf.id, admin))?.abschnitte).toHaveLength(3); await testPrisma.lauf.update({ where: { id: lauf.id }, data: { abteilungId: vertrieb.id } }); const nachher = await laufZumAbarbeiten(lauf.id, admin); expect(nachher?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual([ "Vorbereitung", "Buchhaltung", "Abschluss", ]); }); it("lässt den Abschluss zu, obwohl ein ausgefilterter Pflichtschritt existiert", async () => { // Ohne diese Regel wäre der Abschluss unerreichbar: der Pflichtschritt der // Buchhaltung ließe sich in einem Vertriebs-Lauf nie abhaken. const { admin, vertrieb, versionId } = await aufbau(VORLAGE); const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin); const ergebnisse = await testPrisma.schrittergebnis.findMany({ where: { laufId: lauf.id } }); for (const ergebnis of ergebnisse) { await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: ergebnis.vorlagenSchrittId, zustand: SchrittZustand.ERLEDIGT, }, admin, ); } const abgeschlossen = await laufAbschliessen(lauf.id, admin); expect(abgeschlossen.status).toBe(LaufStatus.ABGESCHLOSSEN); }); it("zeigt in der Druckansicht genau dieselben Abschnitte", async () => { const { admin, vertrieb, versionId } = await aufbau(VORLAGE); const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin); const abarbeitung = await laufZumAbarbeiten(lauf.id, admin); const druck = await laufZumDrucken(lauf.id, admin); expect(druck?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual( abarbeitung?.abschnitte.map((abschnitt) => abschnitt.titel), ); expect(druck?.abschnitte.map((abschnitt) => abschnitt.titel)).not.toContain("Buchhaltung"); }); it("weist einen Lauf ab, für den kein Abschnitt übrig bleibt", async () => { const { admin, versionId } = await aufbau([ { titel: "Nur Buchhaltung", nurFuerAbteilungen: ["Buchhaltung"], schritte: [{ titel: "DATEV" }], }, ]); await expect(laufAnlegen({ vorlagenVersionId: versionId }, admin)).rejects.toThrow(LaufFehler); await expect(laufAnlegen({ vorlagenVersionId: versionId }, admin)).rejects.toThrow(/kein Abschnitt/); }); it("lässt einen Lauf ohne Einschränkungen unverändert – geltendeAbschnitte zählt alle", async () => { // Kein Sonderfall im Leseweg: die Liste enthält auch dann alle Abschnitte. const { admin, versionId } = await aufbau([ { titel: "Eins", schritte: [{ titel: "a" }] }, { titel: "Zwei", schritte: [{ titel: "b" }] }, ]); const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin); const zeile = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } }); expect(zeile.geltendeAbschnitte).toHaveLength(2); expect((await laufZumAbarbeiten(lauf.id, admin))?.abschnitte).toHaveLength(2); }); it("zeigt bei einem Lauf von vor dieser Fassung alle Abschnitte", async () => { // `geltendeAbschnitte` leer heißt alle – so verhalten sich bestehende Läufe // unverändert, ohne dass eine Migration Daten nachführen müsste. const { admin, versionId } = await aufbau(VORLAGE); const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin); await testPrisma.lauf.update({ where: { id: lauf.id }, data: { geltendeAbschnitte: [] } }); const daten = await laufZumAbarbeiten(lauf.id, admin); expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual([ "Vorbereitung", "Buchhaltung", "Abschluss", ]); }); });