Files
Laufzettel/tests/zustand-route.test.ts
T
Florian WildeandClaude Opus 5 1be73b266c
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt
melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine
LISTEN-Verbindung je Prozess hoert zu, und der Route Handler schiebt
weiter. Gemessen: der Haken des Kollegen ist nach 485 ms beim Zuschauer
(am Handy 487 ms), vorher bis zu zehn Sekunden. Zwanzig offene Laschen
kosten nichts mehr statt zwanzig Abfragen je zehn Sekunden.

Das Ereignis kommt aus der Datenbank und nicht aus dem Dienst, aus
demselben Grund, aus dem auch die Regeln dort stehen: ein Trigger sieht
auch den Seed, eine Migration und die Reparatur per psql. Und NOTIFY
erreicht jede Instanz der Anwendung, ohne einen zweiten Dienst.

Welche Tabellen melden, haengt an dem, was `laufZustand` liefert — nicht
daran, was das Issue vorgeschlagen hat. Mit Triggern nur auf
Schrittergebnis und Lauf waere der Strom ein **Rueckschritt** gewesen:
das Polling verglich den ganzen Stand und sah deshalb auch Kommentare,
ein Strom sieht nur, was gemeldet wird. Der Kommentar des Kollegen waere
nie angekommen, und die Seite haette ausgesehen wie immer. Es melden
jetzt vier Tabellen, und `laufZustand` traegt `anzahlAnhaenge` — ein
Ereignis ohne Entsprechung im Zustand loest nichts aus.

Das Polling bleibt als Rueckfall an **derselben** Adresse, unterschieden
nur am Accept-Kopf: eine Rechtepruefung, ein Lesemodell, eine Nutzlast.
Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand
rechtErforderlich vergessen kann. Bei erreichter Obergrenze antwortet
der Handler gewoehnlich mit JSON; EventSource meldet darauf einen Fehler
und versucht es nicht wieder, also faellt der Client von sich aus
zurueck — ohne Sonderfall.

Nebenbei zwei Fehler in docs/betrieb/reverse-proxy.md behoben, beide aus
#52: `client_max_body_size 8m` bei `ANHANG_MAX_MB=10` haette ein
10-MB-Foto mit 413 abgewiesen, bevor die eigene Meldung zum Zuge kommt.

Closes #53

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 22:50:32 +02:00

288 lines
12 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, von dem die Aktualisierung lebt (#013, Strom seit #033).
*
* 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.
*
* Seit #033 kommt der Stand ueber einen Ereignisstrom statt alle zehn Sekunden.
* Am Lesemodell aendert das nichts — **aber an seiner Vollstaendigkeit hängt
* jetzt mehr**: das Polling verglich den ganzen Stand und sah damit auch, was
* kein Trigger meldet. Was hier fehlt, loest nun gar keine Aktualisierung mehr
* aus. Deshalb zaehlt der Test unten die Schluessel namentlich auf, und
* `tests/lauf-ereignisse.test.ts` prueft, dass zu jedem eine Meldung gehoert.
*/
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([
"anzahlAnhaenge",
"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)" } });
const mitTitel = JSON.stringify(await laufZustand(lauf.id, benutzer));
expect(mitTitel).not.toBe(mitKommentar);
/*
* Und ein Anhang (#032/#033). Derselbe Grund wie beim Kommentar: ein Foto,
* das der Kollege gerade an einen Schritt gehaengt hat, blieb sonst
* unsichtbar — und seit der Stand nicht mehr alle zehn Sekunden vollstaendig
* verglichen wird, bliebe es das fuer immer.
*/
const ergebnis = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id },
select: { id: true },
});
await testPrisma.anhang.create({
data: {
schrittergebnisId: ergebnis.id,
laufId: lauf.id,
kundeId: lauf.kundeId,
dateiname: "typenschild.png",
medientyp: "image/png",
groesse: 42,
pruefsumme: "c".repeat(64),
},
});
expect(JSON.stringify(await laufZustand(lauf.id, benutzer))).not.toBe(mitTitel);
});
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");
});
});