import { expect, test, type Page } from "@playwright/test"; import { ADMIN_EMAIL, ADMIN_PASSWORT, WEHR_SLUG } from "./wehr"; /** * 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 = WEHR_SLUG; /** Aus dem Seed: Andreas Huber, Mitgliedsnummer 0001. */ const MITGLIEDSNUMMER = "0001"; const PIN = "471203"; /** * 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 { 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 { 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 { 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(); }); });