import { beforeEach, describe, expect, it } from "vitest"; import { Betriebsmodus, LaufStatus, Rolle, SchrittTyp, SchrittZustand, VariablenTyp, VersionStatus, } from "@/generated/prisma/enums"; import { LaufFehler, laufAbschliessen, laufAnlegen, laufArchivieren, laufKopfdatenAendern, laufMitAdKontoVerknuepfen, laufMussVeraenderbarSein, laufZurBearbeitungOeffnen, laufZurueckholen, titelVorschlag, titelVorschlagErmitteln, } from "@/server/services/laeufe"; import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse"; import { kommentarHinzufuegen } from "@/server/services/kommentare"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Läufe: anlegen, starten, abschließen, archivieren (#013). * * Die Zusage, an der der Wert des ganzen Archivs hängt: ein archivierter Lauf * ändert sich nicht mehr, und nur ein ADMIN hebt das auf. Dazu die Regeln, die * einen Lauf überhaupt brauchbar machen — er hängt für immer an seiner Version, * `OFFEN` ist der Zuweisungszustand, und die Mandantengrenze gilt an jeder * Kennung, die von außen kommt. */ async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) { const eigene = await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global", modus } }); 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 }, }); const leserZeile = await testPrisma.benutzer.create({ data: { email: "leser@firma.example", name: "Lars Leser", rolle: Rolle.LESER }, }); return { eigene, admin: { id: adminZeile.id, rolle: Rolle.ADMIN }, techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER }, leser: { id: leserZeile.id, rolle: Rolle.LESER }, }; } type VariablenVorgabe = { readonly schluessel: string; readonly beschriftung: string; readonly typ?: VariablenTyp; readonly optionen?: readonly string[]; readonly pflicht?: boolean; }; /** * Eine Vorlage mit einer veröffentlichten Version 1: vier Schritte, drei davon * Pflicht. * * Der Inhalt entsteht **vor** dem Veröffentlichen — die Trigger aus #009 lassen * an einer veröffentlichten Version nichts mehr einfügen, und genau das ist der * Sinn dieser Tests von der anderen Seite. */ async function vorlageMitVersion(vorgabe?: { readonly kundeId?: string | null; readonly variablen?: readonly VariablenVorgabe[]; readonly name?: string; }) { const vorlage = await testPrisma.vorlage.create({ data: { name: vorgabe?.name ?? "PC-Neuinstallation", kundeId: vorgabe?.kundeId ?? null }, }); const version = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF }, }); for (const [index, variable] of (vorgabe?.variablen ?? []).entries()) { await testPrisma.vorlagenVariable.create({ data: { versionId: version.id, schluessel: variable.schluessel, beschriftung: variable.beschriftung, typ: variable.typ ?? VariablenTyp.TEXT, optionen: [...(variable.optionen ?? [])], pflicht: variable.pflicht ?? false, position: index, }, }); } 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 notizSchritt = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: vorbereitung.id, titel: "Auffälligkeiten notiert", typ: SchrittTyp.TEXT, pflicht: false, position: 1, }, }); 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 { vorlage, version, abschnitte: { vorbereitung, konto }, schritte: { sicherung, notizSchritt, kontoAngelegt, gruppen }, pflichtIds: [sicherung.id, kontoAngelegt.id, gruppen.id], }; } /** Setzt die genannten Schritte auf ERLEDIGT – über den Dienst, wie die Oberfläche. */ async function erledigen( laufId: string, schrittIds: readonly string[], benutzer: { readonly id: string; readonly rolle: Rolle }, ): Promise { for (const vorlagenSchrittId of schrittIds) { await schrittergebnisSetzen({ laufId, vorlagenSchrittId, zustand: SchrittZustand.ERLEDIGT }, benutzer); } } async function aktionen(laufId: string): Promise { const eintraege = await testPrisma.protokoll.findMany({ where: { laufId }, orderBy: { erstelltAm: "asc" }, }); return eintraege.map((eintrag) => eintrag.aktion); } describe.runIf(datenbankVerfuegbar)("Läufe", () => { beforeEach(async () => { await datenbankLeeren(); delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; }); describe("Anlegen und Zuweisen", () => { // Akzeptanzkriterium 1 it("legt einen Lauf ohne Mitarbeiter und ohne Gerät an, schließt ihn ab und archiviert ihn", async () => { const { admin, eigene } = await grundbestand(); const { version, pflichtIds } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); expect(lauf.status).toBe(LaufStatus.OFFEN); expect(lauf.mitarbeiterId).toBeNull(); expect(lauf.geraetId).toBeNull(); expect(lauf.kundeId).toBe(eigene.id); expect(lauf.erstelltVonId).toBe(admin.id); // Die Ergebniszeilen entstehen beim Anlegen, damit der Fortschritt ab der // ersten Sekunde stimmt. expect(await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id } })).toBe(4); await erledigen(lauf.id, pflichtIds, admin); const abgeschlossen = await laufAbschliessen(lauf.id, admin); expect(abgeschlossen.status).toBe(LaufStatus.ABGESCHLOSSEN); expect(abgeschlossen.abgeschlossenVonId).toBe(admin.id); expect(abgeschlossen.abgeschlossenAm).not.toBeNull(); const archiviert = await laufArchivieren(lauf.id, admin); expect(archiviert.status).toBe(LaufStatus.ARCHIVIERT); expect(archiviert.archiviertVonId).toBe(admin.id); expect(archiviert.archiviertAm).not.toBeNull(); expect(await aktionen(lauf.id)).toEqual( expect.arrayContaining(["Lauf angelegt", "Lauf gestartet", "Lauf abgeschlossen", "Lauf archiviert"]), ); }); // Akzeptanzkriterium 6 it("lässt gestartetAm leer, solange der Lauf nur OFFEN und zugewiesen ist", async () => { const { admin, techniker } = await grundbestand(); const { version } = await vorlageMitVersion(); const lauf = await laufAnlegen( { vorlagenVersionId: version.id, zugewiesenAnId: techniker.id, ticket: "TICK-7" }, admin, ); expect(lauf.status).toBe(LaufStatus.OFFEN); expect(lauf.gestartetAm).toBeNull(); expect(lauf.zugewiesenAnId).toBe(techniker.id); // Ansehen startet nichts: das Lesemodell rührt den Lauf nicht an, und auch // nach dem Lesen der Kopfdaten steht gestartetAm noch leer. const gelesen = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } }); expect(gelesen.gestartetAm).toBeNull(); expect(await aktionen(lauf.id)).toEqual(["Lauf angelegt"]); }); it("startet den Lauf beim ersten Öffnen zur Bearbeitung und bleibt danach idempotent", async () => { const { admin, techniker } = await grundbestand(); const { version } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); const erstes = await laufZurBearbeitungOeffnen(lauf.id, techniker); expect(erstes.status).toBe(LaufStatus.IN_ARBEIT); expect(erstes.gestartetAm).not.toBeNull(); const zweites = await laufZurBearbeitungOeffnen(lauf.id, techniker); expect(zweites.gestartetAm?.getTime()).toBe(erstes.gestartetAm?.getTime()); expect((await aktionen(lauf.id)).filter((a) => a === "Lauf gestartet")).toHaveLength(1); }); it("wirft einen abgeschlossenen oder archivierten Lauf beim Öffnen nicht zurück", async () => { const { admin } = await grundbestand(); const { version, pflichtIds } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await erledigen(lauf.id, pflichtIds, admin); await laufAbschliessen(lauf.id, admin); expect((await laufZurBearbeitungOeffnen(lauf.id, admin)).status).toBe(LaufStatus.ABGESCHLOSSEN); await laufArchivieren(lauf.id, admin); // Ein archivierter Lauf lässt sich aufschlagen, ohne dass das Öffnen wirft — // sonst wäre er nicht mehr ansehbar. expect((await laufZurBearbeitungOeffnen(lauf.id, admin)).status).toBe(LaufStatus.ARCHIVIERT); }); it("schließt einen Lauf ohne Pflichtschritte direkt aus OFFEN ab, mit Startzeitpunkt", async () => { const { admin } = await grundbestand(); const vorlage = await testPrisma.vorlage.create({ data: { name: "Patchday" } }); const version = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF }, }); const abschnitt = await testPrisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: "Durchsicht", position: 0 }, }); await testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Kurz geschaut", pflicht: false, position: 0 }, }); await testPrisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, }); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); const abgeschlossen = await laufAbschliessen(lauf.id, admin); // Lauf_begonnen_mit_zeitpunkt in der Datenbank verlangt den Startzeitpunkt, // und ein abgeschlossener Lauf ohne ihn sähe im Archiv wie ein Fehler aus. expect(abgeschlossen.gestartetAm).not.toBeNull(); expect(abgeschlossen.status).toBe(LaufStatus.ABGESCHLOSSEN); }); it("lehnt einen Entwurf und eine archivierte Vorlage als Grundlage ab", async () => { const { admin } = await grundbestand(); const { vorlage, version } = await vorlageMitVersion(); const entwurf = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 2, status: VersionStatus.ENTWURF }, }); await expect(laufAnlegen({ vorlagenVersionId: entwurf.id }, admin)).rejects.toThrow(LaufFehler); await expect(laufAnlegen({ vorlagenVersionId: entwurf.id }, admin)).rejects.toThrow( /veröffentlichte Version/, ); await testPrisma.vorlage.update({ where: { id: vorlage.id }, data: { archiviert: true } }); await expect(laufAnlegen({ vorlagenVersionId: version.id }, admin)).rejects.toThrow(/archiviert/); }); it("lehnt Person und Freitextname zugleich ab", async () => { const { admin, eigene } = await grundbestand(); const { version } = await vorlageMitVersion(); const person = await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace" }, }); await expect( laufAnlegen( { vorlagenVersionId: version.id, mitarbeiterId: person.id, mitarbeiterName: "Ada L." }, admin, ), ).rejects.toThrow(/beides zugleich/); expect(await testPrisma.lauf.count()).toBe(0); }); it("nimmt einen deaktivierten Mitarbeiter an – das Offboarding braucht ihn", async () => { const { admin, eigene } = await grundbestand(); const { version } = await vorlageMitVersion(); const person = await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, vorname: "Moritz", nachname: "Geht", anzeigename: "Moritz Geht", aktiv: false, }, }); const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, admin); expect(lauf.mitarbeiterId).toBe(person.id); }); it("lehnt ein archiviertes Gerät ab", async () => { const { admin, eigene } = await grundbestand(); const { version } = await vorlageMitVersion(); const geraet = await testPrisma.geraet.create({ data: { kundeId: eigene.id, tagCode: "LZ-AAAAAA", hostname: "PC-ALT-01", archiviert: true, registriertAm: new Date(), }, }); await expect( laufAnlegen({ vorlagenVersionId: version.id, geraetId: geraet.id }, admin), ).rejects.toThrow(/archiviert/); }); it("weist eine Zuweisung an einen LESER und an ein deaktiviertes Konto ab", async () => { const { admin, leser } = await grundbestand(); const { version } = await vorlageMitVersion(); const gesperrt = await testPrisma.benutzer.create({ data: { email: "weg@firma.example", name: "Wanda Weg", rolle: Rolle.TECHNIKER, aktiv: false }, }); await expect( laufAnlegen({ vorlagenVersionId: version.id, zugewiesenAnId: leser.id }, admin), ).rejects.toThrow(/darf Läufe nicht bearbeiten/); await expect( laufAnlegen({ vorlagenVersionId: version.id, zugewiesenAnId: gesperrt.id }, admin), ).rejects.toThrow(/deaktiviert/); }); }); describe("Variablen", () => { it("nimmt gültige Werte auf und weist fehlende, unbekannte und falsche ab", async () => { const { admin } = await grundbestand(); const { version } = await vorlageMitVersion({ variablen: [ { schluessel: "os_version", beschriftung: "Betriebssystem", pflicht: true }, { schluessel: "join_typ", beschriftung: "Einbindung", typ: VariablenTyp.AUSWAHL, optionen: ["Active Directory", "Entra ID"], }, { schluessel: "speicher_gb", beschriftung: "Speicher in GB", typ: VariablenTyp.ZAHL }, ], }); await expect(laufAnlegen({ vorlagenVersionId: version.id }, admin)).rejects.toThrow(/„Betriebssystem“/); await expect( laufAnlegen( { vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", tipfehler: "x" } }, admin, ), ).rejects.toThrow(/„tipfehler“/); await expect( laufAnlegen( { vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", join_typ: "Novell" } }, admin, ), ).rejects.toThrow(/keine Auswahl/); await expect( laufAnlegen( { vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", speicher_gb: "viel" } }, admin, ), ).rejects.toThrow(/erwartet eine Zahl/); const lauf = await laufAnlegen( { vorlagenVersionId: version.id, variablen: { os_version: " Windows 11 Pro ", join_typ: "Entra ID", speicher_gb: 32 }, }, admin, ); expect(lauf.variablen).toEqual({ os_version: "Windows 11 Pro", join_typ: "Entra ID", speicher_gb: 32, }); }); }); describe("Titelvorschlag", () => { it("baut – – ", () => { const datum = new Date("2026-09-25T10:00:00Z"); expect(titelVorschlag({ vorlage: "Onboarding", geraet: "NB-ADA", datum })).toBe( "Onboarding – NB-ADA – 25.09.2026", ); // Der Hostname gewinnt: er steht auf dem Gerät, vor dem der Techniker sitzt. expect(titelVorschlag({ vorlage: "Onboarding", geraet: "NB-ADA", person: "Ada Lovelace", datum })).toBe( "Onboarding – NB-ADA – 25.09.2026", ); expect(titelVorschlag({ vorlage: "Onboarding", person: "Ada Lovelace", datum })).toBe( "Onboarding – Ada Lovelace – 25.09.2026", ); // Fehlt der Bezug, fällt er weg statt als leere Stelle zu erscheinen. expect(titelVorschlag({ vorlage: "Patchday", datum })).toBe("Patchday – 25.09.2026"); }); it("löst den Vorschlag auf, bevor der Lauf existiert", async () => { const { admin, eigene } = await grundbestand(); const { version } = await vorlageMitVersion(); const geraet = await testPrisma.geraet.create({ data: { kundeId: eigene.id, tagCode: "LZ-BBBBBB", hostname: "PC-VERTRIEB-07", registriertAm: new Date(), }, }); const vorschlag = await titelVorschlagErmitteln( { vorlagenVersionId: version.id, geraetId: geraet.id }, admin, ); expect(vorschlag).toContain("PC-Neuinstallation"); expect(vorschlag).toContain("PC-VERTRIEB-07"); expect(await testPrisma.lauf.count()).toBe(0); // Und das Anlegen ohne eigenen Titel setzt genau diesen Vorschlag ein. const lauf = await laufAnlegen({ vorlagenVersionId: version.id, geraetId: geraet.id }, admin); expect(lauf.titel).toBe(vorschlag); }); }); describe("Abschließen", () => { // Akzeptanzkriterium 2 it("scheitert, solange ein Pflichtschritt OFFEN ist, und nennt ihn", async () => { const { admin } = await grundbestand(); const { version, schritte } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await erledigen(lauf.id, [schritte.sicherung.id, schritte.kontoAngelegt.id], admin); await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(LaufFehler); // Nicht „es fehlen noch Schritte“, sondern welche. await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/Konto im AD · Gruppen zugewiesen/); expect((await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } })).status).toBe( LaufStatus.IN_ARBEIT, ); }); it("zählt FEHLGESCHLAGEN nicht als erledigt", async () => { const { admin } = await grundbestand(); const { version, schritte, pflichtIds } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await erledigen( lauf.id, pflichtIds.filter((id) => id !== schritte.gruppen.id), admin, ); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "Gruppenrichtlinie greift nicht, Ticket an das Netzwerkteam.", }, admin, ); await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/fehlgeschlagener Schritt/); // NICHT_ZUTREFFEND mit Begründung macht den Weg frei. await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.NICHT_ZUTREFFEND, notiz: "Der Kunde pflegt Gruppen selbst.", }, admin, ); expect((await laufAbschliessen(lauf.id, admin)).status).toBe(LaufStatus.ABGESCHLOSSEN); }); it("lässt einen offenen Nicht-Pflichtschritt durchgehen", async () => { const { admin } = await grundbestand(); const { version, pflichtIds } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await erledigen(lauf.id, pflichtIds, admin); expect((await laufAbschliessen(lauf.id, admin)).status).toBe(LaufStatus.ABGESCHLOSSEN); }); it("ist beim zweiten Abschließen still – ein Doppelklick ist kein Fehler", async () => { const { admin } = await grundbestand(); const { version, pflichtIds } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await erledigen(lauf.id, pflichtIds, admin); const erstes = await laufAbschliessen(lauf.id, admin); const zweites = await laufAbschliessen(lauf.id, admin); expect(zweites.abgeschlossenAm?.getTime()).toBe(erstes.abgeschlossenAm?.getTime()); expect((await aktionen(lauf.id)).filter((a) => a === "Lauf abgeschlossen")).toHaveLength(1); }); it("hält zwei gleichzeitige Abschlüsse und ein Starten dazwischen aus", async () => { const { admin, techniker } = await grundbestand(); const { version, pflichtIds } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await erledigen(lauf.id, pflichtIds, admin); // Zwei Techniker am selben Lauf: die Zeilensperre reiht die Schreibzugriffe // auf, es entsteht genau ein Abschluss. await Promise.all([ laufAbschliessen(lauf.id, admin), laufAbschliessen(lauf.id, techniker), laufZurBearbeitungOeffnen(lauf.id, techniker), ]); const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } }); expect(nachher.status).toBe(LaufStatus.ABGESCHLOSSEN); expect((await aktionen(lauf.id)).filter((a) => a === "Lauf abgeschlossen")).toHaveLength(1); }); }); describe("Archiv", () => { // Akzeptanzkriterium 4 it("lehnt an einem archivierten Lauf Schrittergebnis, Kommentar und Kopfdaten ab", async () => { const { admin } = await grundbestand(); const { version, schritte, pflichtIds } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await erledigen(lauf.id, pflichtIds, admin); const ergebnis = await testPrisma.schrittergebnis.findFirstOrThrow({ where: { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id }, }); await laufAbschliessen(lauf.id, admin); await laufArchivieren(lauf.id, admin); await expect( schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.notizSchritt.id, zustand: SchrittZustand.ERLEDIGT, }, admin, ), ).rejects.toThrow(/archiviert/); await expect( kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Noch ein Nachtrag." }, admin), ).rejects.toThrow(/archiviert/); await expect( kommentarHinzufuegen( { laufId: lauf.id, schrittergebnisId: ergebnis.id, rumpf: "Und hier auch." }, admin, ), ).rejects.toThrow(/archiviert/); await expect(laufKopfdatenAendern(lauf.id, { ticket: "TICK-NEU" }, admin)).rejects.toThrow( /archiviert/, ); await expect(laufMitAdKontoVerknuepfen(lauf.id, { upn: "ada@firma.local" }, admin)).rejects.toThrow( /archiviert/, ); // Und es ist wirklich nichts angekommen. const unberuehrt = await testPrisma.schrittergebnis.findFirstOrThrow({ where: { laufId: lauf.id, vorlagenSchrittId: schritte.notizSchritt.id }, }); expect(unberuehrt.zustand).toBe(SchrittZustand.OFFEN); expect(await testPrisma.kommentar.count({ where: { laufId: lauf.id } })).toBe(0); }); // Akzeptanzkriterium 5 it("holt nur ein ADMIN einen archivierten Lauf zurück, mit Protokolleintrag", async () => { const { admin, techniker } = await grundbestand(); const { version, pflichtIds } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await erledigen(lauf.id, pflichtIds, admin); await laufAbschliessen(lauf.id, admin); await laufArchivieren(lauf.id, admin); await expect(laufZurueckholen(lauf.id, techniker)).rejects.toThrow(/Administrator/); expect((await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } })).status).toBe( LaufStatus.ARCHIVIERT, ); const zurueck = await laufZurueckholen(lauf.id, admin); expect(zurueck.status).toBe(LaufStatus.ABGESCHLOSSEN); expect(zurueck.archiviertAm).toBeNull(); expect(zurueck.archiviertVonId).toBeNull(); const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { laufId: lauf.id, aktion: "Lauf aus dem Archiv zurückgeholt" }, }); expect(eintrag.benutzerId).toBe(admin.id); // Und danach ist der Lauf wieder änderbar. const geaendert = await laufKopfdatenAendern(lauf.id, { ticket: "TICK-9" }, admin); expect(geaendert.ticket).toBe("TICK-9"); }); it("archiviert nur einen abgeschlossenen Lauf und bleibt beim zweiten Mal still", async () => { const { admin } = await grundbestand(); const { version, pflichtIds } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await expect(laufArchivieren(lauf.id, admin)).rejects.toThrow(/Erst abschließen/); await erledigen(lauf.id, pflichtIds, admin); await laufAbschliessen(lauf.id, admin); const erstes = await laufArchivieren(lauf.id, admin); const zweites = await laufArchivieren(lauf.id, admin); expect(zweites.archiviertAm?.getTime()).toBe(erstes.archiviertAm?.getTime()); expect((await aktionen(lauf.id)).filter((a) => a === "Lauf archiviert")).toHaveLength(1); }); it("hält den Schreibschutz an genau einer Stelle fest", () => { // Ein abgeschlossener Lauf bleibt änderbar – nachgetragene Notizen und // Kommentare sind der Normalfall. Erst das Archivieren sperrt. for (const status of [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT, LaufStatus.ABGESCHLOSSEN]) { expect(() => laufMussVeraenderbarSein({ status, titel: "Ein Lauf" })).not.toThrow(); } expect(() => laufMussVeraenderbarSein({ status: LaufStatus.ARCHIVIERT, titel: "Ein Lauf" })).toThrow( LaufFehler, ); }); }); describe("Bindung an die Version", () => { it("bleibt an seiner Version, auch wenn danach eine neue veröffentlicht wird", async () => { const { admin } = await grundbestand(); const { vorlage, version, pflichtIds } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); // Version 2 mit einem zusätzlichen Pflichtschritt. const zweite = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 2, status: VersionStatus.ENTWURF }, }); const abschnitt = await testPrisma.vorlagenAbschnitt.create({ data: { versionId: zweite.id, titel: "Neu", position: 0 }, }); await testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "BitLocker geprüft", pflicht: true, position: 0 }, }); await testPrisma.vorlagenVersion.update({ where: { id: zweite.id }, data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, }); // Der laufende Lauf kennt den neuen Pflichtschritt nicht: die drei // Pflichtschritte aus Version 1 genügen zum Abschluss. await erledigen(lauf.id, pflichtIds, admin); const abgeschlossen = await laufAbschliessen(lauf.id, admin); expect(abgeschlossen.vorlagenVersionId).toBe(version.id); expect(await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id } })).toBe(4); // Und es gibt keinen Weg, ihn auf die neue Version zu heben. await expect(laufKopfdatenAendern(lauf.id, { vorlagenVersionId: zweite.id }, admin)).rejects.toThrow( /gestartet wurde/, ); }); }); describe("Kopfdaten", () => { it("ändert nur, was sich wirklich ändert, und protokolliert es", async () => { const { admin, techniker } = await grundbestand(); const { version } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id, ticket: "TICK-1" }, admin); const unveraendert = await laufKopfdatenAendern(lauf.id, { ticket: "TICK-1" }, admin); expect(unveraendert.geaendertAm.getTime()).toBe(lauf.geaendertAm.getTime()); expect(await aktionen(lauf.id)).toEqual(["Lauf angelegt"]); const faellig = new Date("2026-10-01T08:00:00Z"); const geaendert = await laufKopfdatenAendern( lauf.id, { ticket: "TICK-2", zugewiesenAnId: techniker.id, faelligAm: faellig, titel: "Neuer Titel" }, admin, ); expect(geaendert.ticket).toBe("TICK-2"); expect(geaendert.zugewiesenAnId).toBe(techniker.id); expect(geaendert.faelligAm?.getTime()).toBe(faellig.getTime()); expect(geaendert.titel).toBe("Neuer Titel"); const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { laufId: lauf.id, entitaet: "Lauf", aktion: { contains: "geändert" } }, }); expect(eintrag.vorher).toMatchObject({ Ticket: "TICK-1" }); expect(eintrag.nachher).toMatchObject({ Ticket: "TICK-2", Titel: "Neuer Titel" }); }); it("prüft geänderte Variablen erneut und meldet nur echte Änderungen", async () => { const { admin } = await grundbestand(); const { version } = await vorlageMitVersion({ variablen: [ { schluessel: "os_version", beschriftung: "Betriebssystem", pflicht: true }, { schluessel: "notiz", beschriftung: "Bemerkung" }, ], }); const lauf = await laufAnlegen( { vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", notiz: "eilig" } }, admin, ); // Dieselben Werte in anderer Reihenfolge sind keine Änderung. const gleich = await laufKopfdatenAendern( lauf.id, { variablen: { notiz: "eilig", os_version: "Windows 11" } }, admin, ); expect(gleich.geaendertAm.getTime()).toBe(lauf.geaendertAm.getTime()); expect(await aktionen(lauf.id)).toEqual(["Lauf angelegt"]); // Eine Pflichtvariable lässt sich auch nachträglich nicht leeren. await expect(laufKopfdatenAendern(lauf.id, { variablen: { notiz: "eilig" } }, admin)).rejects.toThrow( /„Betriebssystem“/, ); const geaendert = await laufKopfdatenAendern( lauf.id, { variablen: { os_version: "Windows 11 Pro", notiz: "eilig" } }, admin, ); expect(geaendert.variablen).toEqual({ os_version: "Windows 11 Pro", notiz: "eilig" }); expect(await aktionen(lauf.id)).toContain("Variablen geändert"); }); it("lehnt einen leeren Titel ab", async () => { const { admin } = await grundbestand(); const { version } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin); await expect(laufKopfdatenAendern(lauf.id, { titel: " " }, admin)).rejects.toThrow(/Titel/); }); }); describe("Mit AD-Konto verknüpfen", () => { it("verbindet einen Freitextnamen über den UPN und behält den alten Namen im Protokoll", async () => { const { admin, eigene } = await grundbestand(); const { version } = await vorlageMitVersion(); const lauf = await laufAnlegen( { vorlagenVersionId: version.id, mitarbeiterName: "Ada Lovelace (neu)" }, admin, ); const person = await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace", upn: "ada.lovelace@firma.local", }, }); const verknuepft = await laufMitAdKontoVerknuepfen(lauf.id, { upn: "ADA.LOVELACE@firma.local" }, admin); expect(verknuepft.mitarbeiterId).toBe(person.id); // Die Bedingung Lauf_mitarbeiter_eindeutig schließt beides zugleich aus. expect(verknuepft.mitarbeiterName).toBeNull(); const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { laufId: lauf.id, aktion: "Mit AD-Konto verknüpft" }, }); expect(eintrag.vorher).toMatchObject({ mitarbeiterName: "Ada Lovelace (neu)" }); expect(eintrag.nachher).toMatchObject({ anzeigename: "Ada Lovelace" }); }); it("lehnt ein zweites Konto und einen unbekannten UPN ab", async () => { const { admin, eigene } = await grundbestand(); const { version } = await vorlageMitVersion(); const person = await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace", upn: "ada@firma.local", }, }); const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, admin); await expect(laufMitAdKontoVerknuepfen(lauf.id, { mitarbeiterId: person.id }, admin)).rejects.toThrow( /hängt schon eine Person/, ); const zweiter = await laufAnlegen( { vorlagenVersionId: version.id, mitarbeiterName: "Niemand Bekannt" }, admin, ); await expect( laufMitAdKontoVerknuepfen(zweiter.id, { upn: "gibt.es.nicht@firma.local" }, admin), ).rejects.toThrow(/kein Konto/); }); }); describe("Mandantengrenze", () => { it("verwirft im Modus INTERN eine mitgeschickte kundeId", async () => { const { admin, eigene } = await grundbestand(); const { version } = await vorlageMitVersion(); const fremder = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } }); const lauf = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: fremder.id }, admin); expect(lauf.kundeId).toBe(eigene.id); }); it("lehnt Gerät, Person und Abteilung eines fremden Kunden ab", async () => { const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG); const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } }); const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KB" } }); const { version } = await vorlageMitVersion(); const geraetB = await testPrisma.geraet.create({ data: { kundeId: kundeB.id, tagCode: "LZ-CCCCCC", hostname: "PC-B-01", registriertAm: new Date(), }, }); const personB = await testPrisma.mitarbeiter.create({ data: { kundeId: kundeB.id, vorname: "Bea", nachname: "Bee", anzeigename: "Bea Bee" }, }); const abteilungB = await testPrisma.abteilung.create({ data: { kundeId: kundeB.id, name: "Buchhaltung" }, }); for (const eingabe of [ { geraetId: geraetB.id }, { mitarbeiterId: personB.id }, { abteilungId: abteilungB.id }, ]) { await expect( laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id, ...eingabe }, admin), ).rejects.toThrow(/anderen Kunden/); } expect(await testPrisma.lauf.count()).toBe(0); }); it("lehnt eine kundenspezifische Vorlage bei einem anderen Kunden ab", async () => { const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG); const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } }); const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KB" } }); const { version } = await vorlageMitVersion({ kundeId: kundeB.id, name: "Nur für B" }); await expect(laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id }, admin)).rejects.toThrow( /anderen Kunden/, ); expect((await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeB.id }, admin)).kundeId).toBe( kundeB.id, ); }); it("lässt einen Techniker nicht an den Lauf eines fremden Kunden", async () => { process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "laufzettel-"; const { admin, techniker } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG); const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } }); const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KB" } }); await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: kundeB.id }, }); const { version } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id }, admin); // Kennung allein öffnet keinen fremden Mandanten – in jeder Funktion. await expect(laufZurBearbeitungOeffnen(lauf.id, techniker)).rejects.toThrow(/Berechtigung/); await expect(laufKopfdatenAendern(lauf.id, { ticket: "X" }, techniker)).rejects.toThrow(/Berechtigung/); await expect(laufAbschliessen(lauf.id, techniker)).rejects.toThrow(/Berechtigung/); await expect(kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Hallo" }, techniker)).rejects.toThrow( /Berechtigung/, ); }); }); });