Files
Laufzettel/tests/zustand-route.test.ts
T
Florian WildeandClaude Opus 5 ed6e6b81eb
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen und archiviert. Der
Archiv-Schreibschutz sitzt in genau einer Funktion und wird vor jeder Aenderung
an Kopfdaten, Schrittergebnis und Kommentar gerufen; abgeschlossen bleibt
aenderbar, archiviert nicht.

Die Abarbeitungsansicht speichert jeden Schritt einzeln, frischt sich alle zehn
Sekunden auf und pausiert im Hintergrund. Sie traegt in der Polling-Antwort
keine Befehle und laedt sich auch nicht von selbst neu, solange ein generiertes
Passwort auf der Seite steht: beides tauschte es dem Techniker zwischen Lesen
und Einfuegen aus.

Der unabhaengige Review fand zwei Blocker: ein Befehl konnte zwei verschiedene
Passwoerter tragen (Konto bekommt A, Benutzer erfaehrt B), und „nicht
zutreffend“ liess sich nicht zuruecknehmen, waehrend die Oberflaeche
behauptete, nichts sei gewaehlt. Beides behoben und in Tests festgehalten.

Closes #13

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 18:55:59 +02:00

256 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
});
});