import { readFile } from "node:fs/promises"; import { beforeEach, describe, expect, it } from "vitest"; import { Betriebsmodus, LaufStatus, SchrittZustand } from "@/generated/prisma/enums"; import { laufZumAbarbeiten, laufZustand } from "@/server/queries/laeufe"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Der Zustand fuer die Aktualisierung alle zehn Sekunden (#013). * * Zwei Zusagen haengen daran: zwei Browser am selben Lauf sehen die Aenderung * des anderen — und die Antwort enthaelt **keine Befehle**, weil ein * `{{generiertes_passwort}}` bei jeder Aufloesung neu entsteht und dem Techniker * sonst unter den Haenden wegtauschte. */ async function aufbau() { const eigene = await testPrisma.kunde.create({ data: { name: "Muster", kuerzel: "MUS", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } }); const benutzer = await testPrisma.benutzer.create({ data: { email: "t@firma.example", name: "Ali Demir", rolle: "TECHNIKER" }, }); const vorlage = await testPrisma.vorlage.create({ data: { name: "PC" } }); const version = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" }, }); const abschnitt = await testPrisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: "Installation", position: 0 }, }); const schritte = await Promise.all([ testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Pflicht", pflicht: true, position: 0 }, }), testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Konto", typ: "BEFEHL", pflicht: true, position: 1, konfig: { shell: "powershell", befehl: '-Password "{{generiertes_passwort}}"' }, }, }), /* * Ein zweiter Befehl mit demselben Platzhalter – der Fall, an dem der Review * zu #013 den Blocker gefunden hat: anlegen und uebergeben sind zwei * Schritte und muessen dasselbe Passwort meinen. */ testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Passwort übergeben", typ: "BEFEHL", position: 2, konfig: { shell: "powershell", befehl: 'Write-Host "{{generiertes_passwort}}"' }, }, }), ]); await testPrisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() }, }); const lauf = await testPrisma.lauf.create({ data: { vorlagenVersionId: version.id, titel: "PC-001", kundeId: eigene.id }, }); await testPrisma.schrittergebnis.createMany({ data: schritte.map((schritt) => ({ laufId: lauf.id, vorlagenSchrittId: schritt.id })), }); return { eigene, lauf, schritte, benutzer: { id: benutzer.id, rolle: benutzer.rolle } }; } describe.runIf(datenbankVerfuegbar)("Zustand eines Laufs", () => { beforeEach(async () => { await datenbankLeeren(); }); it("zählt die offenen Pflichtschritte", async () => { const { lauf, schritte, benutzer } = await aufbau(); expect((await laufZustand(lauf.id, benutzer))?.anzahlPflichtOffen).toBe(2); await testPrisma.schrittergebnis.updateMany({ where: { laufId: lauf.id, vorlagenSchrittId: schritte[0]!.id }, data: { zustand: SchrittZustand.ERLEDIGT }, }); expect((await laufZustand(lauf.id, benutzer))?.anzahlPflichtOffen).toBe(1); }); it("zählt einen nicht zutreffenden Schritt als vom Tisch", async () => { // „Nicht zutreffend“ heisst: bewusst uebersprungen, mit Begruendung. Als // offen zu zaehlen hiesse, den Abschluss zu verhindern, den die Regel // ausdruecklich erlaubt. const { lauf, schritte, benutzer } = await aufbau(); await testPrisma.schrittergebnis.updateMany({ where: { laufId: lauf.id, vorlagenSchrittId: schritte[0]!.id }, data: { zustand: SchrittZustand.NICHT_ZUTREFFEND, notiz: "kein AD-Konto nötig" }, }); expect((await laufZustand(lauf.id, benutzer))?.anzahlPflichtOffen).toBe(1); }); it("zählt einen fehlgeschlagenen Schritt **nicht** als vom Tisch", async () => { const { lauf, schritte, benutzer } = await aufbau(); await testPrisma.schrittergebnis.updateMany({ where: { laufId: lauf.id, vorlagenSchrittId: schritte[0]!.id }, data: { zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "Server antwortet nicht" }, }); expect((await laufZustand(lauf.id, benutzer))?.anzahlPflichtOffen).toBe(2); }); it("liefert, wer wann bearbeitet hat", async () => { // Zwei Browser am selben Lauf sollen sehen, dass der andere etwas getan hat // — und wer es war. const { lauf, schritte, benutzer } = await aufbau(); await testPrisma.schrittergebnis.updateMany({ where: { laufId: lauf.id, vorlagenSchrittId: schritte[0]!.id }, data: { zustand: SchrittZustand.ERLEDIGT, aktualisiertVonId: benutzer.id }, }); const zustand = await laufZustand(lauf.id, benutzer); const schritt = zustand?.schritte.find((eintrag) => eintrag.schrittId === schritte[0]!.id); expect(schritt?.bearbeitetVon).toBe("Ali Demir"); expect(schritt?.bearbeitetAm).toBeInstanceOf(Date); }); it("enthält keinen einzigen Befehl", async () => { /* * Die Zusage, an der alles haengt: ein `{{generiertes_passwort}}` entsteht * bei jeder Aufloesung neu. Kaeme der Befehl alle zehn Sekunden mit, taeusche * die Anzeige ein Passwort vor, das schon nicht mehr gilt — und der * Techniker tippt es gerade ab. */ const { lauf, benutzer } = await aufbau(); const zustand = await laufZustand(lauf.id, benutzer); const alsText = JSON.stringify(zustand); expect(alsText).not.toContain("Password"); expect(alsText).not.toContain("{{"); expect(Object.keys(zustand ?? {}).sort()).toEqual([ "anzahlKommentare", "anzahlPflichtOffen", "geaendertAm", "schritte", "status", ]); }); it("meldet auch einen Kommentar und eine Änderung an den Kopfdaten", async () => { /* * „Zwei Browser sehen die Arbeit des anderen“ galt vorher nur fuer * Schrittzustaende: ein Kommentar des Kollegen und ein geaenderter Titel * blieben unsichtbar, bis jemand von sich aus neu lud (Reviewbefund zu * #013). Der Client vergleicht die Antwort als Ganzes — was hier fehlt, * loest keine Aktualisierung aus. */ const { lauf, benutzer } = await aufbau(); const vorher = JSON.stringify(await laufZustand(lauf.id, benutzer)); await testPrisma.kommentar.create({ data: { laufId: lauf.id, benutzerId: benutzer.id, rumpf: "Rückfrage ans Team" }, }); const mitKommentar = JSON.stringify(await laufZustand(lauf.id, benutzer)); expect(mitKommentar).not.toBe(vorher); await testPrisma.lauf.update({ where: { id: lauf.id }, data: { titel: "PC-001 (neuer Titel)" } }); expect(JSON.stringify(await laufZustand(lauf.id, benutzer))).not.toBe(mitKommentar); }); it("liefert den Status mit", async () => { const { lauf, benutzer } = await aufbau(); expect((await laufZustand(lauf.id, benutzer))?.status).toBe(LaufStatus.OFFEN); }); it("gibt einen Lauf eines fremden Kunden nicht heraus", async () => { const { lauf, benutzer } = await aufbau(); const fremder = await testPrisma.kunde.create({ data: { name: "Fremd", kuerzel: "FRD" } }); await testPrisma.einstellung.update({ where: { id: "global" }, data: { modus: Betriebsmodus.DIENSTLEISTUNG }, }); process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-"; await testPrisma.benutzerKunde.create({ data: { benutzerId: benutzer.id, kundeId: fremder.id } }); expect(await laufZustand(lauf.id, benutzer)).toBeUndefined(); }); it("liefert nichts für einen Lauf, den es nicht gibt", async () => { const { benutzer } = await aufbau(); expect(await laufZustand("gibtsnicht", benutzer)).toBeUndefined(); }); }); describe.runIf(datenbankVerfuegbar)("Ein Lauf, ein Passwort je Seitenaufbau", () => { beforeEach(datenbankLeeren); it("setzt in alle Befehle einer Seite dasselbe Passwort", async () => { /* * Der Blocker aus dem Review zu #013. Ein Onboarding legt das Konto in einem * Schritt an und uebergibt das Passwort im naechsten; standen dort zwei * verschiedene, kam der Benutzer nicht in sein Konto — und niemand konnte * sagen, welches das richtige war, weil keines gespeichert wird. */ const { lauf, benutzer } = await aufbau(); const daten = await laufZumAbarbeiten(lauf.id, benutzer); const befehle = (daten?.abschnitte ?? []).flatMap((a) => a.schritte.map((schritt) => schritt.befehl?.text).filter((text): text is string => !!text), ); expect(befehle).toHaveLength(2); const passwoerter = befehle.map((text) => /"([^"]+)"/u.exec(text)?.[1]); expect(passwoerter[0]).toHaveLength(20); expect(passwoerter[0]).toBe(passwoerter[1]); // Und das Skript des Abschnitts traegt dasselbe, nicht ein drittes. expect(daten?.abschnitte[0]?.skript).toContain(passwoerter[0]); }); it("erzeugt beim nächsten Aufbau ein anderes", async () => { // Die Regel „bei jeder Anzeige neu“ bleibt: gespeichert wird nichts. const { lauf, benutzer } = await aufbau(); const einmal = (await laufZumAbarbeiten(lauf.id, benutzer))?.abschnitte[0]?.schritte[1]?.befehl?.text; const nochmal = (await laufZumAbarbeiten(lauf.id, benutzer))?.abschnitte[0]?.schritte[1]?.befehl?.text; expect(einmal).toBeDefined(); expect(einmal).not.toBe(nochmal); }); }); /** * Die zweite Haelfte derselben Zusage (#013). * * Dass die Polling-Antwort keine Befehle traegt, nuetzt nichts, wenn das Polling * stattdessen die ganze Seite neu bauen laesst: beim naechsten Aufbau entsteht * ein anderes `{{generiertes_passwort}}` als das, das der Techniker gerade * gelesen hat. Steht eines auf der Seite, wird deshalb gefragt statt geladen. */ describe("Die Aktualisierung tauscht kein angezeigtes Passwort aus", () => { it("fragt, wenn ein Passwort auf der Seite steht, und laedt sonst von selbst", async () => { const { reaktionAufAenderung } = await import("@/components/laeufe/lauf-aktualisierung"); expect(reaktionAufAenderung(true)).toBe("fragen"); expect(reaktionAufAenderung(false)).toBe("neu-laden"); }); it("reicht die Angabe von der Laufseite durch", async () => { /* * Ein Blick in den Quelltext, und zwar bewusst: fuer Client-Komponenten gibt * es in diesem Projekt keine DOM-Umgebung, und die Entscheidung oben waere * wertlos, wenn die Seite sie nie mitgibt. Der Test prueft genau die * Verdrahtung — nicht mehr, aber auch nicht weniger. */ const quelle = await readFile(new URL("../src/app/(app)/laeufe/[id]/page.tsx", import.meta.url), "utf8"); expect(quelle).toContain("passwortSichtbar={passwortSichtbar}"); expect(quelle).toMatch(/passwortSichtbar\s*=\s*abschnitte\.some\(/u); expect(quelle).toContain("mitPasswort === true"); }); });