Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz weg ist. Datenmodell: Fahrt, Anwesenheit, Waescheabgabe, Inventurgang, Inventurposten, KioskVorgang und SyncKonflikt. Die Kennung eines Vorgangs erzeugt der Browser beim Einreihen und ist damit der Idempotenzschluessel; kommt derselbe Vorgang nach abgebrochener Uebertragung erneut an, erkennt ihn der Server wieder. Die Datenbank setzt vier Regeln selbst durch und verweigert UPDATE und DELETE auf kiosk_vorgang. Offline: von Hand geschriebener Service Worker, Warteschlange in der IndexedDB (Dexie), Fotos als Blob daneben, sichtbarer Zustand als Leiste ueber allem. Was sich beim Nachtragen nicht anwenden laesst, landet als Konflikt unter /terminal statt verworfen zu werden. Dazu: Anmeldung per Ausweis, PIN oder signierter QR-Marke vom Handy, Hallenmonitor mit optionalen DWD-Warnungen, Installation als App. Behoben: die Warteschlange zog ihren Wiederholungstakt bei jedem Rendern neu auf und haette einen liegen gebliebenen Vorgang nie ein zweites Mal geschickt. Gefunden vom Offline-E2E-Test. 31 neue Tests (20 Unit, 11 gegen eine echte Datenbank, 9 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
/**
|
|
* E2E-Tests für Phase 4 — das Terminal im Gerätehaus.
|
|
*
|
|
* Der wichtigste Test dieser Phase ist der Offline-Fall: das Tablet verliert
|
|
* das Netz, es wird trotzdem weiter erfasst, und sobald das Netz zurück ist,
|
|
* kommt alles beim Server an — genau einmal.
|
|
*
|
|
* Playwright kann das ehrlich prüfen: `context.setOffline(true)` kappt die
|
|
* Verbindung des Browsers wirklich, statt sie nur zu behaupten.
|
|
*
|
|
* Nacheinander statt parallel: die Tests hängen an gemeinsamem Serverzustand
|
|
* (wer ist angemeldet, was liegt in der Warteschlange) und stören sich sonst
|
|
* gegenseitig — dasselbe Muster wie bei den Prüfungen aus Phase 2.
|
|
*
|
|
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
|
|
* docker compose up -d && npm run db:migrate && npm run db:seed
|
|
*/
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
const GERAETE_SCHLUESSEL = process.env.KIOSK_DEVICE_SECRET ?? "bitte_aendern_kiosk_geheimnis";
|
|
const WEHR = "ff-musterhausen";
|
|
|
|
/** Aus dem Seed: Andreas Huber, Mitgliedsnummer 0001. */
|
|
const MITGLIEDSNUMMER = "0001";
|
|
const PIN = "471203";
|
|
|
|
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
|
|
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
|
|
|
|
/**
|
|
* Beide Playwright-Projekte (Desktop und Tablet) laufen gegen dieselbe
|
|
* Datenbank. Damit sich die Testdaten nicht überlagern, trägt jedes seine
|
|
* eigene Kennzeichnung.
|
|
*/
|
|
function kennzeichen(): string {
|
|
return test.info().project.name;
|
|
}
|
|
|
|
/** Richtet das Tablet ein — einmal je Browserkontext. */
|
|
async function terminalEinrichten(page: Page): Promise<void> {
|
|
await page.goto(
|
|
`/kiosk/einrichten?wehr=${WEHR}&schluessel=${encodeURIComponent(GERAETE_SCHLUESSEL)}`,
|
|
);
|
|
await expect(page.getByRole("heading", { name: "Terminal einrichten" })).toBeVisible();
|
|
|
|
// Erst wenn die Felder aus der Adresse gefüllt sind, hat das Formular etwas
|
|
// abzuschicken — sie kommen aus einem Effekt, also nach der Hydration.
|
|
await expect(page.getByLabel("Kurzname der Wehr")).toHaveValue(WEHR);
|
|
await expect(page.getByLabel("Gerätegeheimnis")).toHaveValue(GERAETE_SCHLUESSEL);
|
|
|
|
await page.getByRole("button", { name: "Terminal einrichten" }).click();
|
|
await page.waitForURL("**/kiosk");
|
|
}
|
|
|
|
/** Richtet ein und meldet die Person mit PIN an. */
|
|
async function terminalAnmelden(page: Page): Promise<void> {
|
|
await terminalEinrichten(page);
|
|
|
|
await expect(page.getByRole("heading", { name: "Anmelden" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "PIN" }).click();
|
|
await page.getByLabel("Mitgliedsnummer").fill(MITGLIEDSNUMMER);
|
|
await page.getByLabel("PIN", { exact: true }).fill(PIN);
|
|
await page.getByRole("button", { name: "Anmelden" }).click();
|
|
|
|
// Danach stehen die Kacheln da — erkennbar an der Begrüßung und der
|
|
// Zustandsleiste, die auf jeder Terminalseite mitläuft.
|
|
await expect(page.getByText("Angemeldet als")).toBeVisible();
|
|
await expect(page.getByTestId("kiosk-zustand")).toBeVisible();
|
|
}
|
|
|
|
/** Meldet sich in der normalen Verwaltungsoberfläche an. */
|
|
async function verwaltungAnmelden(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.describe("Terminal", () => {
|
|
test("verlangt die Einrichtung, bevor sich jemand anmelden kann", async ({ page }) => {
|
|
// Frischer Kontext, also leerer localStorage: das Tablet kennt keine Wehr.
|
|
await page.goto("/kiosk");
|
|
await expect(page.getByRole("heading", { name: "Terminal nicht eingerichtet" })).toBeVisible();
|
|
});
|
|
|
|
test("meldet mit Mitgliedsnummer und PIN an", async ({ page }) => {
|
|
await terminalAnmelden(page);
|
|
|
|
// Die Kacheln richten sich nach den Rechten — Kommen/Gehen hat jeder.
|
|
await expect(page.getByText("Anwesenheit erfassen").or(page.getByText("da seit"))).toBeVisible();
|
|
await expect(page.getByText("Mangel melden")).toBeVisible();
|
|
});
|
|
|
|
test("weist eine falsche PIN ab", async ({ page }) => {
|
|
await terminalEinrichten(page);
|
|
|
|
await page.getByRole("button", { name: "PIN" }).click();
|
|
await page.getByLabel("Mitgliedsnummer").fill(MITGLIEDSNUMMER);
|
|
await page.getByLabel("PIN", { exact: true }).fill("000000");
|
|
await page.getByRole("button", { name: "Anmelden" }).click();
|
|
|
|
await expect(page.getByTestId("anmeldefehler")).toBeVisible();
|
|
await expect(page.getByTestId("anmeldefehler")).toContainText("Mitgliedsnummer und PIN");
|
|
});
|
|
|
|
test("erfasst Kommen oder Gehen mit zwei Tipps", async ({ page }) => {
|
|
await terminalAnmelden(page);
|
|
|
|
await page.goto("/kiosk/anwesenheit");
|
|
|
|
// Erst wenn der eigene Stand geladen ist, steht fest, was der Wizard
|
|
// anbietet — vorher steht dort ein Platzhalter.
|
|
await expect(page.getByTestId("kiosk-laedt")).toHaveCount(0);
|
|
|
|
// Je nachdem, ob aus einem früheren Lauf noch eine Anwesenheit offen ist,
|
|
// steht hier „Kommen" oder „Gehen". Beides ist ein gültiger Zustand.
|
|
const gehen = page.getByRole("button", { name: "Jetzt gehen" });
|
|
if (await gehen.isVisible()) {
|
|
await gehen.click();
|
|
} else {
|
|
await page.getByRole("button", { name: /Übung/ }).click();
|
|
}
|
|
|
|
await expect(page.getByTestId("kiosk-erledigt")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Offline-Betrieb", () => {
|
|
/** Werden erfasst und im jeweils folgenden Test in der Verwaltung wiedergefunden. */
|
|
let mangeltext = "";
|
|
let waeschestueck = "";
|
|
|
|
test("erfasst ohne Netz und überträgt, sobald das Netz zurück ist", async ({ page, context }) => {
|
|
await terminalAnmelden(page);
|
|
|
|
// Einmal mit Netz laden — so, wie das Tablet im Gerätehaus hängt, bevor
|
|
// der Router streikt.
|
|
await page.goto("/kiosk/mangel");
|
|
await expect(page.getByRole("heading", { name: "Mangel melden" })).toBeVisible();
|
|
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offline", "false");
|
|
|
|
// --- Netz weg -----------------------------------------------------------
|
|
await context.setOffline(true);
|
|
|
|
// Die Zustandsleiste sagt es, ohne dass jemand danach suchen muss.
|
|
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offline", "true");
|
|
await expect(page.getByText("Kein Netz")).toBeVisible();
|
|
|
|
// Trotzdem erfassen: Nummer eintippen (eine Kamera gibt es im Test nicht),
|
|
// beschreiben, Dringlichkeit antippen.
|
|
mangeltext = `Offline erfasst (${kennzeichen()}, ${Date.now()}): Halterung lose`;
|
|
|
|
await page.getByLabel("Inventarnummer").fill("INV-0001");
|
|
await page.getByLabel("Beschreibung des Mangels").fill(mangeltext);
|
|
await page.getByRole("button", { name: /Normal/ }).click();
|
|
|
|
// Die Bestätigung kommt sofort — ohne Server.
|
|
await expect(page.getByTestId("kiosk-erledigt")).toBeVisible();
|
|
|
|
// Und der Vorgang wartet sichtbar in der Warteschlange. Die Bestätigung
|
|
// springt nach ein paar Sekunden zurück auf die Kacheln; die Leiste läuft
|
|
// dort weiter, deshalb hält die Zusicherung über den Wechsel hinweg.
|
|
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offen", "1");
|
|
await expect(page.getByText("Änderung wartet")).toBeVisible();
|
|
|
|
// --- Netz zurück --------------------------------------------------------
|
|
await context.setOffline(false);
|
|
|
|
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offline", "false", {
|
|
timeout: 30_000,
|
|
});
|
|
|
|
// Die Warteschlange leert sich von selbst — der Knopf ist nur die Abkürzung
|
|
// für den Ungeduldigen und darf deshalb auch schon verschwunden sein.
|
|
const uebertragen = page.getByRole("button", { name: "Jetzt übertragen" });
|
|
if (await uebertragen.isVisible()) await uebertragen.click();
|
|
|
|
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offen", "0", {
|
|
timeout: 60_000,
|
|
});
|
|
await expect(page.getByText("alles übertragen")).toBeVisible();
|
|
});
|
|
|
|
test("der offline erfasste Mangel steht danach in der Verwaltung", async ({ page }) => {
|
|
expect(mangeltext, "der vorherige Test muss gelaufen sein").not.toBe("");
|
|
|
|
await verwaltungAnmelden(page);
|
|
await page.goto("/maengel");
|
|
|
|
await expect(page.getByText(mangeltext)).toHaveCount(1);
|
|
});
|
|
|
|
test("nimmt denselben Vorgang auch nach verlorener Antwort nur einmal an", async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
// Dieser Test wartet zwangslaeufig auf den Wiederholungstakt der
|
|
// Warteschlange (20 Sekunden) und dazu, unter Last, auf den
|
|
// Entwicklungsserver. Das Standardlimit ist dafuer zu knapp.
|
|
test.setTimeout(180_000);
|
|
|
|
await terminalAnmelden(page);
|
|
|
|
await page.goto("/kiosk/waesche");
|
|
await expect(page.getByRole("heading", { name: "Wäsche abgeben" })).toBeVisible();
|
|
|
|
/**
|
|
* Der Fall, für den es den Idempotenzschlüssel gibt: die Anfrage kommt beim
|
|
* Server an, die Antwort geht auf dem Rückweg verloren. Das Terminal weiß
|
|
* nicht, ob gebucht wurde, und schickt denselben Vorgang mit derselben
|
|
* Kennung noch einmal.
|
|
*
|
|
* Richtig ist dann: der Server erkennt die Kennung wieder, bucht nicht
|
|
* erneut und meldet Erfolg — die Warteschlange wird leer, und in der Liste
|
|
* steht genau ein Eintrag.
|
|
*/
|
|
let ersteAntwortVerschluckt = false;
|
|
await context.route("**/api/trpc/**", async (route) => {
|
|
const adresse = route.request().url();
|
|
if (!ersteAntwortVerschluckt && adresse.includes("kiosk.vorgaengeSenden")) {
|
|
ersteAntwortVerschluckt = true;
|
|
// Anfrage geht durch (der Server bucht), die Antwort kommt nie an.
|
|
await route.fetch();
|
|
await route.abort("connectionreset");
|
|
return;
|
|
}
|
|
await route.continue();
|
|
});
|
|
|
|
waeschestueck = `E2E-Idempotenz ${kennzeichen()} ${Date.now()}`;
|
|
|
|
await page.getByRole("button", { name: /Sonstiges/ }).click();
|
|
await page.getByLabel("Was genau?").fill(waeschestueck);
|
|
await page.getByRole("button", { name: /Routine/ }).click();
|
|
|
|
// Die Bestätigung kommt sofort aus der Warteschlange, ohne den Server.
|
|
await expect(page.getByTestId("kiosk-erledigt")).toBeVisible();
|
|
|
|
// Den zweiten Versuch anstoßen, statt auf den Takt der Warteschlange zu
|
|
// warten: der Knopf wird von Playwright erst geklickt, wenn er bedienbar
|
|
// ist — also nachdem der abgebrochene erste Versuch beendet ist. Sonst
|
|
// hängt der Test an zwanzig Sekunden Wartezeit, die unter Last knapp wird.
|
|
await page.getByRole("button", { name: "Jetzt übertragen" }).click();
|
|
|
|
// Der zweite Versuch läuft durch — der Vorgang ist damit erledigt.
|
|
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offen", "0", {
|
|
timeout: 60_000,
|
|
});
|
|
|
|
expect(ersteAntwortVerschluckt, "die erste Übertragung muss unterbrochen worden sein").toBe(
|
|
true,
|
|
);
|
|
|
|
// Erst abräumen, wenn nichts mehr unterwegs ist — sonst schlägt eine noch
|
|
// laufende Umleitung beim Testende zu.
|
|
await context.unrouteAll({ behavior: "ignoreErrors" });
|
|
});
|
|
|
|
test("die doppelt gesendete Wäscheabgabe steht genau einmal in der Übersicht", async ({
|
|
page,
|
|
}) => {
|
|
expect(waeschestueck, "der vorherige Test muss gelaufen sein").not.toBe("");
|
|
|
|
// Eigener Browserkontext, denn im vorigen läuft noch die Terminalsitzung —
|
|
// und wer angemeldet ist, sieht auf /anmelden kein Anmeldeformular mehr.
|
|
await verwaltungAnmelden(page);
|
|
await page.goto("/terminal");
|
|
|
|
await expect(page.getByText(waeschestueck)).toHaveCount(1);
|
|
});
|
|
});
|
|
|
|
test.describe("Hallenmonitor", () => {
|
|
test("zeigt Uhrzeit, Anwesende, Fahrzeuge und fällige Prüfungen", async ({ page }) => {
|
|
await terminalAnmelden(page);
|
|
await page.goto("/kiosk/monitor");
|
|
|
|
await expect(page.getByText("Im Haus")).toBeVisible();
|
|
await expect(page.getByText("Fahrzeuge", { exact: true }).first()).toBeVisible();
|
|
// Die Uhr läuft — das Format genügt als Nachweis.
|
|
await expect(page.getByText(/^\d{2}:\d{2}(:\d{2})?$/).first()).toBeVisible();
|
|
});
|
|
});
|