import { beforeEach, describe, expect, it } from "vitest"; import { Betriebsmodus, LaufStatus, Rolle, SchrittTyp, SchrittZustand, VersionStatus, } from "@/generated/prisma/enums"; import { LaufFehler, laufAbschliessen, laufAnlegen } from "@/server/services/laeufe"; import { abschnittNichtZutreffend, schrittergebnisSetzen } from "@/server/services/schrittergebnisse"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Schrittergebnisse (#013). * * Zwei Regeln stehen hier im Mittelpunkt. Die Notizpflicht bei * `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` — ohne sie steht im Archiv ein * übersprungener Schritt ohne Begründung, und das ist genau die Lücke, die ein * Archiv wertlos macht. Und die Abschnitts-Aktion, die achtzehn Haken mit * derselben Begründung erledigt, ohne die Arbeit zu löschen, die jemand schon * getan hat. */ async function grundbestand() { const eigene = await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } }); const adminZeile = await testPrisma.benutzer.create({ data: { email: "admin@firma.example", name: "Alex Admin", rolle: Rolle.ADMIN }, }); const technikerZeile = await testPrisma.benutzer.create({ data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER }, }); return { eigene, admin: { id: adminZeile.id, rolle: Rolle.ADMIN }, techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER }, }; } /** * Eine veröffentlichte Version mit zwei Abschnitten: „Vorbereitung“ mit einem * Pflicht-Haken und einem Zahlenschritt, „Konto im AD“ mit zwei Pflichtschritten * — der Abschnitt, der im Onboarding je nach Kunde gar nicht zutrifft. */ async function vorlageMitVersion() { const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } }); const version = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF }, }); const vorbereitung = await testPrisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: "Vorbereitung", position: 0 }, }); const sicherung = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: vorbereitung.id, titel: "Datensicherung geprüft", pflicht: true, position: 0 }, }); const speicher = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: vorbereitung.id, titel: "Freier Speicher in GB", typ: SchrittTyp.ZAHL, pflicht: false, position: 1, konfig: { min: 20, max: 2000, einheit: "GB" }, }, }); const konto = await testPrisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: "Konto im AD", position: 1 }, }); const kontoAngelegt = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: konto.id, titel: "Konto angelegt", pflicht: true, position: 0 }, }); const gruppen = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: konto.id, titel: "Gruppen zugewiesen", pflicht: true, position: 1 }, }); await testPrisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, }); return { version, abschnitte: { vorbereitung, konto }, schritte: { sicherung, speicher, kontoAngelegt, gruppen }, pflichtIds: [sicherung.id, kontoAngelegt.id, gruppen.id], }; } async function eintraege(laufId: string, entitaet: string): Promise { const zeilen = await testPrisma.protokoll.findMany({ where: { laufId, entitaet }, orderBy: { erstelltAm: "asc" }, }); return zeilen.map((zeile) => zeile.aktion); } describe.runIf(datenbankVerfuegbar)("Schrittergebnisse", () => { beforeEach(async () => { await datenbankLeeren(); delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; }); describe("Notizpflicht", () => { // Akzeptanzkriterium 3 it("lehnt NICHT_ZUTREFFEND ohne Notiz ab und nimmt es mit Notiz an", async () => { const { admin } = await grundbestand(); const { version, schritte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await expect( schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.NICHT_ZUTREFFEND, }, admin, ), ).rejects.toThrow(LaufFehler); await expect( schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.NICHT_ZUTREFFEND, notiz: " ", }, admin, ), ).rejects.toThrow(/Begründung/); const ergebnis = await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.NICHT_ZUTREFFEND, notiz: "Der Kunde pflegt Gruppen selbst.", }, admin, ); expect(ergebnis.zustand).toBe(SchrittZustand.NICHT_ZUTREFFEND); expect(ergebnis.notiz).toBe("Der Kunde pflegt Gruppen selbst."); expect(ergebnis.aktualisiertVonId).toBe(admin.id); }); it("verlangt auch bei FEHLGESCHLAGEN eine Notiz", async () => { const { admin } = await grundbestand(); const { version, schritte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await expect( schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.kontoAngelegt.id, zustand: SchrittZustand.FEHLGESCHLAGEN, }, admin, ), ).rejects.toThrow(/Notiz/); }); it("prüft den Zustand danach: eine vorhandene Notiz genügt, das Leeren nicht", async () => { const { admin } = await grundbestand(); const { version, schritte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "Gruppenrichtlinie greift nicht.", }, admin, ); // Wechsel des Zustands ohne Notiz-Feld: die vorhandene Begründung bleibt. const gewechselt = await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.NICHT_ZUTREFFEND, }, admin, ); expect(gewechselt.notiz).toBe("Gruppenrichtlinie greift nicht."); // Das Leeren im selben Zug fällt auf die Pflicht. await expect( schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.NICHT_ZUTREFFEND, notiz: null, }, admin, ), ).rejects.toThrow(/Begründung/); }); }); describe("Setzen", () => { it("startet den Lauf beim ersten Schrittergebnis", async () => { const { admin, techniker } = await grundbestand(); const { version, schritte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); expect(lauf.gestartetAm).toBeNull(); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT }, techniker, ); const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } }); expect(nachher.status).toBe(LaufStatus.IN_ARBEIT); expect(nachher.gestartetAm).not.toBeNull(); expect(await eintraege(lauf.id, "Lauf")).toContain("Lauf gestartet"); }); // Akzeptanzkriterium 7 it("schreibt jede Änderung ins Protokoll – und eine Wiederholung nicht", async () => { const { admin } = await grundbestand(); const { version, schritte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT }, admin, ); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.OFFEN }, admin, ); // Zweimal dasselbe: ein Doppelklick soll keinen zweiten Eintrag ergeben. await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.OFFEN }, admin, ); expect(await eintraege(lauf.id, "Schrittergebnis")).toEqual([ "Schritt erledigt", "Schritt zurückgesetzt", ]); const letzter = await testPrisma.protokoll.findFirstOrThrow({ where: { laufId: lauf.id, aktion: "Schritt zurückgesetzt" }, }); expect(letzter.vorher).toMatchObject({ zustand: SchrittZustand.ERLEDIGT }); expect(letzter.nachher).toMatchObject({ schritt: "Datensicherung geprüft", pflicht: true, zustand: SchrittZustand.OFFEN, }); }); it("behält den Wert beim Zustandswechsel und löscht ihn nur auf Ansage", async () => { const { admin } = await grundbestand(); const { version, schritte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); const mitWert = await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.speicher.id, zustand: SchrittZustand.ERLEDIGT, wert: { zahl: 512 }, }, admin, ); expect(mitWert.wert).toEqual({ zahl: 512 }); const ohneWertfeld = await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.speicher.id, zustand: SchrittZustand.OFFEN }, admin, ); expect(ohneWertfeld.wert).toEqual({ zahl: 512 }); const geleert = await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.speicher.id, zustand: SchrittZustand.OFFEN, wert: null, }, admin, ); expect(geleert.wert).toBeNull(); }); it("blockiert eine Zahl außerhalb von Min und Max nicht", async () => { const { admin } = await grundbestand(); const { version, schritte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); // Der Auftrag sagt ausdrücklich: markieren, nicht blockieren. Ein Gerät mit // 4 GB frei ist eine Tatsache, und der Dienst hat sie aufzunehmen. const ergebnis = await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.speicher.id, zustand: SchrittZustand.ERLEDIGT, wert: { zahl: 4 }, }, admin, ); expect(ergebnis.wert).toEqual({ zahl: 4 }); }); it("lehnt einen Schritt ab, der zu diesem Lauf nicht gehört", async () => { const { admin } = await grundbestand(); const { version } = await vorlageMitVersion(); const fremde = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); // Ein Schritt aus einer anderen Version – in der Abarbeitungsansicht // tauchte er nie auf, im Archiv aber schon. await expect( schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: fremde.schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT, }, admin, ), ).rejects.toThrow(/gehört nicht zu diesem Lauf/); await expect( schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: "gibt-es-nicht", zustand: SchrittZustand.ERLEDIGT }, admin, ), ).rejects.toThrow(/gehört nicht zu diesem Lauf/); }); it("nimmt den Abschluss zurück, wenn ein Pflichtschritt wieder offen ist", async () => { const { admin } = await grundbestand(); const { version, schritte, pflichtIds } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); for (const vorlagenSchrittId of pflichtIds) { await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId, zustand: SchrittZustand.ERLEDIGT }, admin, ); } await laufAbschliessen(lauf.id, admin); // Ein abgeschlossener Lauf bleibt änderbar – aber „abgeschlossen“ darf // danach nicht behaupten, alle Pflichtschritte seien erledigt. await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.OFFEN }, admin, ); const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } }); expect(nachher.status).toBe(LaufStatus.IN_ARBEIT); expect(nachher.abgeschlossenAm).toBeNull(); expect(nachher.abgeschlossenVonId).toBeNull(); expect(await eintraege(lauf.id, "Lauf")).toContain("Abschluss zurückgenommen"); }); it("hält zwei Techniker am selben Lauf aus", async () => { const { admin, techniker } = await grundbestand(); const { version, schritte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await Promise.all([ schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT }, admin, ), schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.kontoAngelegt.id, zustand: SchrittZustand.ERLEDIGT, }, techniker, ), ]); const erledigt = await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id, zustand: SchrittZustand.ERLEDIGT }, }); expect(erledigt).toBe(2); // Und nur ein „Lauf gestartet“, obwohl beide gleichzeitig angefangen haben. expect((await eintraege(lauf.id, "Lauf")).filter((a) => a === "Lauf gestartet")).toHaveLength(1); }); it("hält zwei gleichzeitige Änderungen am selben Schritt aus", async () => { const { admin, techniker } = await grundbestand(); const { version, schritte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await Promise.all([ schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT }, admin, ), schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "Sicherung schlug fehl.", }, techniker, ), ]); // Eine Zeile, nicht zwei: der eindeutige Index (laufId, vorlagenSchrittId) // und die Zeilensperre halten zusammen. const zeilen = await testPrisma.schrittergebnis.findMany({ where: { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id }, }); expect(zeilen).toHaveLength(1); expect([SchrittZustand.ERLEDIGT, SchrittZustand.FEHLGESCHLAGEN]).toContain(zeilen[0]?.zustand); }); }); describe("Abschnitt als nicht zutreffend", () => { // Akzeptanzkriterium 8 it("setzt alle Schritte des Abschnitts mit derselben Notiz", async () => { const { admin } = await grundbestand(); const { version, abschnitte, schritte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); const ergebnis = await abschnittNichtZutreffend( { laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Der Kunde verwaltet Konten selbst.", }, admin, ); expect(ergebnis.gesetzt).toBe(2); expect(ergebnis.uebersprungen).toEqual([]); const zeilen = await testPrisma.schrittergebnis.findMany({ where: { laufId: lauf.id, vorlagenSchrittId: { in: [schritte.kontoAngelegt.id, schritte.gruppen.id] }, }, }); expect(zeilen).toHaveLength(2); for (const zeile of zeilen) { expect(zeile.zustand).toBe(SchrittZustand.NICHT_ZUTREFFEND); expect(zeile.notiz).toBe("Der Kunde verwaltet Konten selbst."); } // Der andere Abschnitt bleibt unberührt. const sicherung = await testPrisma.schrittergebnis.findFirstOrThrow({ where: { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id }, }); expect(sicherung.zustand).toBe(SchrittZustand.OFFEN); // Je geänderter Schritt ein Protokolleintrag. expect(await eintraege(lauf.id, "Schrittergebnis")).toEqual([ "Abschnitt als nicht zutreffend eingetragen", "Abschnitt als nicht zutreffend eingetragen", ]); }); it("lässt erledigte Schritte erledigt und meldet sie", async () => { const { admin } = await grundbestand(); const { version, abschnitte, schritte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.kontoAngelegt.id, zustand: SchrittZustand.ERLEDIGT, }, admin, ); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "Gruppen fehlen im AD.", }, admin, ); const ergebnis = await abschnittNichtZutreffend( { laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Kunde macht das selbst." }, admin, ); // „Erledigt“ ist eine Tatsache mit Namen und Zeitstempel; „nicht // zutreffend“ darüberzuschreiben wäre deren Verlust. expect(ergebnis.uebersprungen).toEqual(["Konto angelegt"]); expect(ergebnis.gesetzt).toBe(1); const angelegt = await testPrisma.schrittergebnis.findFirstOrThrow({ where: { laufId: lauf.id, vorlagenSchrittId: schritte.kontoAngelegt.id }, }); expect(angelegt.zustand).toBe(SchrittZustand.ERLEDIGT); // Ein fehlgeschlagener Schritt wird dagegen überschrieben. const gruppen = await testPrisma.schrittergebnis.findFirstOrThrow({ where: { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id }, }); expect(gruppen.zustand).toBe(SchrittZustand.NICHT_ZUTREFFEND); expect(gruppen.notiz).toBe("Kunde macht das selbst."); }); it("verlangt eine Notiz und lehnt einen fremden Abschnitt ab", async () => { const { admin } = await grundbestand(); const { version, abschnitte } = await vorlageMitVersion(); const fremde = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await expect( abschnittNichtZutreffend({ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: " " }, admin), ).rejects.toThrow(/Begründung/); await expect( abschnittNichtZutreffend( { laufId: lauf.id, abschnittId: fremde.abschnitte.konto.id, notiz: "Trifft nicht zu." }, admin, ), ).rejects.toThrow(/gehört nicht zu diesem Lauf/); expect( await testPrisma.protokoll.count({ where: { laufId: lauf.id, entitaet: "Schrittergebnis" } }), ).toBe(0); }); it("schreibt beim zweiten Mal mit derselben Notiz nichts nach", async () => { const { admin } = await grundbestand(); const { version, abschnitte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await abschnittNichtZutreffend( { laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Trifft hier nicht zu." }, admin, ); const zweites = await abschnittNichtZutreffend( { laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Trifft hier nicht zu." }, admin, ); expect(zweites.gesetzt).toBe(0); expect(await eintraege(lauf.id, "Schrittergebnis")).toHaveLength(2); }); }); });