Phase 2: Wartungs- und Pruefmanagement

Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf,
Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche
Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung.

Elf neue Tabellen, alle mit organisationId und Row Level Security. Die
Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger
durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung
mit Verweis auf die alte, beide bleiben lesbar.

79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 12:07:11 +02:00
co-authored by Claude Opus 5
parent 7f866ac35c
commit e503aafc18
97 changed files with 13367 additions and 133 deletions
+225
View File
@@ -0,0 +1,225 @@
import { expect, test, type Page } from "@playwright/test";
/**
* E2E-Tests für Phase 2.
*
* Geprüft wird der Weg, den der Gerätewart wirklich geht: morgens ins
* Fristen-Dashboard schauen, eine Vorlage nachsehen, eine Prüfung durchführen
* und am Ende ein Protokoll in der Hand haben.
*
* Hinweis: Der Wizard-Test legt echte Prüfprotokolle an. Die lassen sich
* absichtlich nicht wieder löschen — genau das ist der Sinn der Sache. Für die
* Beispielwehr ist das unschädlich; vor einer Vorführung hilft ein frischer
* Seed.
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
*/
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
async function anmelden(page: Page): Promise<void> {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
await page.waitForURL("/");
}
test.beforeEach(async ({ page }) => {
await anmelden(page);
});
test.describe("Fristen-Dashboard", () => {
test("zeigt die vier Zeitfenster und die offenen Termine", async ({ page }) => {
await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Prüfungen und Fristen" })).toBeVisible();
// Die vier Töpfe sind gleichzeitig Filter.
for (const bezeichnung of ["überfällig", "diese Woche", "diesen Monat", "nächste 90 Tage"]) {
await expect(page.getByRole("button", { name: new RegExp(bezeichnung) })).toBeVisible();
}
await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible();
// Aus dem Seed entstehen Termine für die Geräte mit Prüfvorgabe.
await expect(page.getByRole("cell", { name: /INV-\d{4}/ }).first()).toBeVisible();
});
test("filtert die Terminliste über ein Zeitfenster", async ({ page }) => {
await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible();
await page.getByRole("button", { name: /überfällig/ }).first().click();
await expect(page.getByRole("button", { name: "Filter aufheben" })).toBeVisible();
});
test("bündelt die Termine nach Fahrzeug und lässt umschalten", async ({ page }) => {
await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Verteilung" })).toBeVisible();
await page.getByLabel("Bündelung wählen").click();
await page.getByRole("option", { name: "nach Gerätetyp" }).click();
// Nach dem Umschalten stehen Gerätetypen in der Verteilungstabelle. Der
// Name steht dort allein in der Zelle — in der Terminliste darunter immer
// zusammen mit der Inventarnummer, deshalb "exact".
await expect(
page.getByRole("cell", { name: "Feuerwehrleine 30 m", exact: true }),
).toBeVisible();
});
});
test.describe("Wartungsvorlagen", () => {
test("zeigt die Vorlagen mit Intervall und Prüfpunkten", async ({ page }) => {
await page.goto("/pruefungen/vorlagen");
await expect(page.getByRole("heading", { name: "Wartungsvorlagen" })).toBeVisible();
await expect(
page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }),
).toBeVisible();
await expect(page.getByRole("cell", { name: "DGUV Vorschrift 3, DIN VDE 0701-0702" })).toBeVisible();
});
test("öffnet die Checkliste einer Vorlage", async ({ page }) => {
await page.goto("/pruefungen/vorlagen");
await page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }).click();
await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible();
await expect(page.getByText("Ansprechdruck der Warneinrichtung")).toBeVisible();
// Der zulässige Bereich steht am Punkt.
await expect(page.getByText(/zulässig 50 bis 60 bar/)).toBeVisible();
});
});
/**
* Nacheinander statt parallel: der Wizard setzt einen vorhandenen Entwurf fort,
* statt einen zweiten anzulegen. Zwei gleichzeitig laufende Tests würden sich
* denselben Entwurf teilen — und der zweite liefe in die Sperre für
* abgeschlossene Protokolle.
*
* Aus demselben Grund bekommt jedes Testprojekt ein eigenes Gerät: Desktop und
* Tablet laufen parallel, aber unter demselben Benutzer.
*/
test.describe.configure({ mode: "serial" });
/** Welches der Leinen-Geräte dieses Projekt prüft. */
function geraetIndex(): number {
return test.info().project.name === "tablet" ? 1 : 0;
}
/** Von der Geräteliste in den Wizard — der Weg des Gerätewarts. */
async function pruefungStarten(page: Page): Promise<void> {
await page.goto("/geraete");
await page.getByLabel("Geräte durchsuchen").fill("Feuerwehrleine");
await page.getByRole("link", { name: /INV-\d{4}/ }).nth(geraetIndex()).click();
await page.getByRole("link", { name: "Prüfung durchführen" }).click();
}
test.describe("Prüfwizard", () => {
test("führt von der Checkliste bis zum unterschriebenen Protokoll", async ({ page }) => {
await pruefungStarten(page);
await page.waitForURL(/\/pruefungen\/neu/);
// Die Checkliste der Leinenprüfung hat vier Punkte: drei Ja/Nein und einen
// Freitext. Vor jedem Schritt wird auf die Schrittanzeige gewartet — sonst
// klickt der Test schneller, als die Oberfläche umschaltet.
for (const nummer of [1, 2, 3, 4]) {
await expect(page.getByText(new RegExp(`Schritt ${nummer} von 4`))).toBeVisible();
// "in Ordnung" exakt: sonst trifft der Name auch "nicht in Ordnung".
const inOrdnung = page.getByRole("button", { name: "in Ordnung", exact: true });
if ((await inOrdnung.count()) > 0) await inOrdnung.click();
const befund = page.getByLabel("Befund");
if ((await befund.count()) > 0) {
await befund.fill("Leine vollständig durch die Hand gezogen, ohne Befund.");
}
await page.getByRole("button", { name: "Weiter" }).click();
}
// Abschluss: Ergebnis ist vorbelegt, Unterschrift fehlt noch.
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled();
// Unterschrift zeichnen — mit der Maus, wie mit dem Finger auf dem Tablet.
// Erst ins Bild holen: die Mauskoordinaten beziehen sich auf das sichtbare
// Fenster, nicht auf das Dokument. Liegt das Feld unterhalb, zeichnet der
// Test sonst ins Leere.
const feld = page.getByLabel(/Unterschriftenfeld/);
await feld.scrollIntoViewIfNeeded();
const rahmen = await feld.boundingBox();
expect(rahmen).not.toBeNull();
await page.mouse.move(rahmen!.x + 30, rahmen!.y + rahmen!.height / 2);
await page.mouse.down();
await page.mouse.move(rahmen!.x + 90, rahmen!.y + 20, { steps: 8 });
await page.mouse.move(rahmen!.x + 150, rahmen!.y + rahmen!.height - 20, { steps: 8 });
await page.mouse.move(rahmen!.x + 210, rahmen!.y + 30, { steps: 8 });
await page.mouse.up();
// Sobald etwas auf dem Feld steht, lässt es sich löschen und übernehmen.
await expect(page.getByRole("button", { name: "Löschen" })).toBeEnabled();
await page.getByRole("button", { name: "Übernehmen" }).click();
await expect(page.getByRole("button", { name: "übernommen" })).toBeVisible();
await page.getByRole("button", { name: "Prüfung abschließen" }).click();
// Danach steht das Protokoll da — mit Nummer und Ergebnis.
await page.waitForURL(/\/pruefungen\/[0-9a-f-]{36}$/);
await expect(page.getByRole("heading", { name: /Prüfprotokoll \d{4}-\d{4}/ })).toBeVisible();
// Genau der Kleinschreibung wegen "exact": darüber steht auch das Feld
// "Abgeschlossen" mit dem Zeitpunkt.
await expect(page.getByText("abgeschlossen", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible();
});
test("verlangt eine Unterschrift, bevor abgeschlossen werden kann", async ({ page }) => {
await pruefungStarten(page);
// Erst warten, bis der Wizard steht — er legt die Prüfung beim Öffnen an.
await expect(page.getByText(/Schritt 1 von \d/)).toBeVisible();
// Dann bis zum Abschluss durchklicken, ohne etwas zu beantworten.
for (let schritt = 0; schritt < 8; schritt += 1) {
const weiter = page.getByRole("button", { name: "Weiter" });
if (!(await weiter.isVisible())) break;
await weiter.click();
}
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled();
});
});
test.describe("Mängel und Rückrufe", () => {
test("zeigt den Mangel aus der Beispielprüfung", async ({ page }) => {
await page.goto("/maengel");
await expect(page.getByRole("heading", { name: "Mängel" })).toBeVisible();
await expect(page.getByText(/Leinenbeutel an einer Naht aufgescheuert/)).toBeVisible();
});
test("zeigt den Rückruf und seine betroffenen Geräte", async ({ page }) => {
await page.goto("/rueckrufe");
await expect(page.getByRole("heading", { name: "Rückrufe" })).toBeVisible();
await page.getByRole("link", { name: /Sicherheitshinweis Ausatemventil/ }).click();
await expect(page.getByRole("heading", { name: "Betroffene Geräte" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Mögliche Treffer im Bestand" })).toBeVisible();
});
});
test.describe("Protokolle", () => {
test("listet abgeschlossene Prüfungen mit Ergebnis", async ({ page }) => {
await page.goto("/pruefungen/verlauf");
await expect(page.getByRole("heading", { name: "Prüfprotokolle" })).toBeVisible();
await expect(page.getByRole("link", { name: /\d{4}-\d{4}/ }).first()).toBeVisible();
});
});