import { expect, test } from "@playwright/test"; import { anmelden, eigenerName } from "./wehr"; /** * E2E-Tests für Phase 7 — Termine, Benachrichtigungen und Dienstausweis. * * Drei Dinge stehen im Mittelpunkt, weil sie den Unterschied zwischen einem * brauchbaren und einem ärgerlichen Kalender ausmachen: * * 1. **Die Rückmeldung** — der Knopf, den jedes Mitglied drückt und sonst * nichts. Er muss ohne Umweg funktionieren und den Stand sofort zeigen. * 2. **Die Absage bleibt sichtbar.** Ein Termin, der ohne ein Wort * verschwindet, führt dazu, dass jemand vor dem verschlossenen Gerätehaus * steht. * 3. **Die Prüfseite des Ausweises ist ohne Anmeldung erreichbar** — und * weist einen erfundenen Code ab. * * Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet. */ test.describe.configure({ mode: "serial" }); test.describe("Terminkalender", () => { test("zeigt die Termine aus dem Bestand", async ({ page }) => { await anmelden(page); await page.goto("/termine"); await expect( page.getByRole("heading", { name: "Termine", level: 1 }), ).toBeVisible(); // Aus dem Seed: der Übungsabend als Serie und der Lehrgang mit Plätzen. await expect( page.getByRole("link", { name: "Übungsabend", exact: true }).first(), ).toBeVisible(); await expect( page.getByRole("link", { name: "Maschinistenlehrgang Teil 1" }), ).toBeVisible(); }); test("zeigt einen abgesagten Termin mit Grund statt ihn zu verstecken", async ({ page, }) => { await anmelden(page); await page.goto("/termine"); const waldfest = page.getByRole("link", { name: "Waldfest der Feuerwehr" }); await expect(waldfest).toBeVisible(); await waldfest.click(); await expect(page.getByText("Dieser Termin fällt aus.")).toBeVisible(); await expect(page.getByText(/Dauerregen angekündigt/)).toBeVisible(); }); test("nimmt eine Zusage entgegen und zeigt sie an", async ({ page }) => { await anmelden(page); await page.goto("/termine"); const jhv = page.getByRole("link", { name: "Jahreshauptversammlung" }); await jhv.click(); await expect( page.getByRole("heading", { name: "Jahreshauptversammlung" }), ).toBeVisible(); // "Absagen" exakt: sonst trifft es auch "Termin absagen" daneben. await page.getByRole("button", { name: "Zusagen", exact: true }).click(); await expect(page.getByText("Aktuell: Zugesagt")).toBeVisible(); // Und wieder zurück — eine Rückmeldung muss sich ändern lassen. await page.getByRole("button", { name: "Absagen", exact: true }).click(); await expect(page.getByText("Aktuell: Abgesagt")).toBeVisible(); }); /** * Der Lehrgang hat acht Plätze und im Seed elf Anmeldungen. Die Warteliste * ist damit der Regelfall, nicht der Sonderfall — sie muss sichtbar sein * und in der Reihenfolge der Anmeldung stehen. * * Bewusst keine eigene Zusage in diesem Test: ob der angemeldete Nutzer * schon einen Platz hat, hängt an der Reihenfolge im Seed. Was hier zählt, * ist die Anzeige der Warteliste — die gilt für alle. */ test("zeigt Belegung und Warteliste eines begrenzten Lehrgangs", async ({ page, }) => { await anmelden(page); await page.goto("/termine"); await page .getByRole("link", { name: "Maschinistenlehrgang Teil 1" }) .click(); await expect(page.getByText(/von 8 belegt/)).toBeVisible(); await expect( page.getByRole("heading", { name: /^Zugesagt \(8\)$/ }), ).toBeVisible(); await expect( page.getByRole("heading", { name: /^Warteliste \(\d+\)$/ }), ).toBeVisible(); // Die Warteliste ist nummeriert — sonst weiß niemand, wer nachrückt. await expect(page.getByText("1.", { exact: false }).first()).toBeVisible(); }); test("legt einen Termin an", async ({ page }) => { const titel = eigenerName("Einzeltermin"); await anmelden(page); await page.goto("/termine"); await page.getByRole("button", { name: "Termin anlegen" }).click(); await page.getByLabel("Titel").fill(titel); await page.getByLabel("Ort").fill("Gerätehaus"); await page.getByRole("button", { name: "Anlegen" }).click(); await expect(page.getByRole("heading", { name: titel })).toBeVisible(); await expect(page.getByText("Gerätehaus")).toBeVisible(); }); /** * Die Zahl, die vor dem Anlegen dasteht. Wer „wöchentlich bis in einem * Jahr" einstellt, soll vorher lesen, dass daraus über fünfzig Einträge * werden — nicht danach. */ test("nennt vor dem Anlegen, wie viele Termine eine Serie ergibt", async ({ page, }) => { await anmelden(page); await page.goto("/termine"); await page.getByRole("button", { name: "Termin anlegen" }).click(); await page.getByLabel("Titel").fill(eigenerName("Serienvorschau")); await page.getByLabel("Wiederholung").click(); await page.getByRole("option", { name: "wöchentlich" }).click(); const inAchtWochen = new Date(Date.now() + 56 * 864e5); await page .getByLabel("Serie läuft bis") .fill(inAchtWochen.toISOString().slice(0, 10)); await expect(page.getByText(/Das legt \d+ Termine an/)).toBeVisible(); }); test("verlangt für eine Absage einen Grund", async ({ page }) => { const titel = eigenerName("Absage"); await anmelden(page); await page.goto("/termine"); await page.getByRole("button", { name: "Termin anlegen" }).click(); await page.getByLabel("Titel").fill(titel); await page.getByRole("button", { name: "Anlegen" }).click(); await expect(page.getByRole("heading", { name: titel })).toBeVisible(); await page.getByRole("button", { name: "Termin absagen" }).click(); // Innerhalb des Dialogs suchen: "Absagen" steht auch auf der // Rückmeldekarte dahinter. const dialog = page.getByRole("dialog"); // Ohne Grund geht es nicht weiter. await expect( dialog.getByRole("button", { name: "Absagen", exact: true }), ).toBeDisabled(); await dialog .getByLabel("Grund") .fill("Sturmwarnung, Übungsgelände nicht befahrbar."); await dialog.getByRole("button", { name: "Absagen", exact: true }).click(); await expect(page.getByText("Dieser Termin fällt aus.")).toBeVisible(); await expect(page.getByText(/Sturmwarnung/)).toBeVisible(); }); test("führt eine Anwesenheitsliste über alle aktiven Mitglieder", async ({ page, }) => { await anmelden(page); await page.goto("/termine"); await page.getByRole("link", { name: "Jahreshauptversammlung" }).click(); await page.getByRole("button", { name: "Anwesenheitsliste" }).click(); await expect( page.getByRole("heading", { name: "Anwesenheitsliste" }), ).toBeVisible(); // Auf einem Blatt zum Abhaken steht jeder — nicht nur die Angemeldeten. await expect( page.getByRole("columnheader", { name: "Anwesend" }), ).toBeVisible(); await expect(page.getByRole("row")).not.toHaveCount(1); }); }); test.describe("Benachrichtigungen", () => { test("sagt deutlich, dass ohne eigenes Zutun nichts kommt", async ({ page, }) => { await anmelden(page); await page.goto("/benachrichtigungen"); await expect( page.getByRole("heading", { name: "Benachrichtigungen", level: 1 }), ).toBeVisible(); await expect( page.getByText(/Niemand wird ungefragt benachrichtigt/), ).toBeVisible(); }); /** * Der Kanal, der über einen fremden Server läuft, muss als solcher zu * erkennen sein — bevor jemand ihn einrichtet, nicht danach. */ test("kennzeichnet externe Kanäle", async ({ page }) => { await anmelden(page); await page.goto("/benachrichtigungen"); await page.getByLabel("Über").click(); await expect(page.getByRole("option", { name: /Telegram/ })).toBeVisible(); await expect( page.getByRole("option", { name: /Browser-Push/ }), ).toBeVisible(); }); test("erzeugt eine Kalenderadresse, die genau einmal sichtbar ist", async ({ page, }) => { await anmelden(page); await page.goto("/benachrichtigungen"); await page.getByLabel("Für welches Gerät?").fill(eigenerName("Kalender")); await page.getByRole("button", { name: "Adresse erzeugen" }).click(); await expect(page.getByText(/wird nur jetzt angezeigt/)).toBeVisible(); const feld = page.locator("input[readonly]"); await expect(feld).toHaveValue(/\/api\/kalender\/.+\.ics$/); // Nach dem Neuladen ist sie weg — gespeichert wird nur der Hash. await page.reload(); await expect(page.getByText(/wird nur jetzt angezeigt/)).toBeHidden(); }); test("liefert unter der Kalenderadresse eine gültige ICS-Datei", async ({ page, request, }) => { await anmelden(page); await page.goto("/benachrichtigungen"); await page.getByLabel("Für welches Gerät?").fill(eigenerName("ICS")); await page.getByRole("button", { name: "Adresse erzeugen" }).click(); const adresse = await page.locator("input[readonly]").inputValue(); const antwort = await request.get(adresse); expect(antwort.status()).toBe(200); expect(antwort.headers()["content-type"]).toContain("text/calendar"); const inhalt = await antwort.text(); expect(inhalt).toContain("BEGIN:VCALENDAR"); expect(inhalt).toContain("Übungsabend"); expect(inhalt).toContain("END:VCALENDAR"); }); test("weist eine erfundene Kalenderadresse ab", async ({ request }) => { const antwort = await request.get("/api/kalender/nichtvorhanden.ics"); expect(antwort.status()).toBe(404); }); test("zeigt die Textvorlagen mit Platzhaltern und Vorschau", async ({ page, }) => { await anmelden(page); await page.goto("/benachrichtigungen/vorlagen"); await expect( page.getByRole("heading", { name: "Textvorlagen", level: 1 }), ).toBeVisible(); await expect(page.getByRole("heading", { name: "Vorschau" })).toBeVisible(); // Die Platzhalter stehen zum Anklicken bereit — als Knopf, nicht nur im Text. await expect(page.getByRole("button", { name: "{titel}" })).toBeVisible(); // In der Vorschau stehen die Beispielwerte, nicht die Platzhalter. await expect( page.getByText("Übungsabend", { exact: false }).first(), ).toBeVisible(); }); test("warnt bei einem Platzhalter, den es für das Ereignis nicht gibt", async ({ page, }) => { await anmelden(page); await page.goto("/benachrichtigungen/vorlagen"); // Erst warten, bis die Seite im Browser lebendig ist. // // Die Platzhalter-Knöpfe entstehen client-seitig; sind sie da, ist // hydriert. Ohne diese Zusicherung wurde unter Last auf „Speichern“ // geklickt, bevor React lief — dann verschickt das Formular sich selbst, // die Seite lädt neu, und die Fehlerfläche entsteht nie. Der Test fiel // dann mit „Element nicht gefunden“ aus, obwohl der Server richtig // ablehnte. await expect(page.getByRole("button", { name: "{titel}" })).toBeVisible(); const feld = page.getByLabel("Nachricht"); await feld.fill("Am {datum} um {uhrzeit}. Und ein {tippfehler}."); await page.getByRole("button", { name: "Speichern" }).click(); await expect(page.getByTestId("vorlagenfehler")).toContainText( "tippfehler", ); }); }); test.describe("Dienstausweis", () => { test("zeigt den eigenen Ausweis mit QR-Code", async ({ page }) => { await anmelden(page); await page.goto("/ausweis"); await expect( page.getByRole("heading", { name: "Dienstausweis", level: 1 }), ).toBeVisible(); await expect( page.getByRole("img", { name: "Dienstausweis als QR-Code" }), ).toBeVisible(); await expect(page.getByText(/Gültig noch/)).toBeVisible(); await expect( page.getByText(/Nach fünf Minuten ist er wertlos/), ).toBeVisible(); }); /** * Die Prüfseite ist ohne Anmeldung erreichbar — wer an der Absperrung * scannt, hat keinen Zugang zu FLORIAN. Ein erfundener Code muss trotzdem * abgewiesen werden, und zwar mit einem Satz, den ein Mensch versteht. */ test("weist einen erfundenen Ausweis ab, ohne Anmeldung", async ({ browser, }) => { const kontext = await browser.newContext(); const seite = await kontext.newPage(); await seite.goto("/ausweis/ausweis.a.b.c.d"); await expect( seite.getByRole("heading", { name: "Ausweis nicht gültig" }), ).toBeVisible(); await expect(seite.getByText(/Diese Seite zeigt an/)).toBeVisible(); await kontext.close(); }); });