Files
florian.wilde 14cc4de9b8
Pruefung / pruefung (push) Canceled after 0s
Pruefung / oberflaeche (push) Canceled after 0s
Phase 11: Freigabereife (#16)
2026-09-06 20:05:49 +00:00

357 lines
12 KiB
TypeScript

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();
});
});