357 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|