ESLint hatte keine Konfiguration — das Skript "npm run lint" pruefte nichts. Jetzt Flat Config ohne Formatierungsregeln, dafuer mit den Regeln, die die Fehler dieses Projekts gemeldet haetten. Er hat gleich zwei gefunden, die niemand gesucht hatte. Das Badge rendert ein span statt eines div. Damit ist die Meldung "div cannot be a descendant of p" weg, die seit Phase 3 auf der Einsatzseite stand — sie ist der klassische Ausloeser fuer einen Hydration-Fehler. Die E2E-Tests laufen gegen eine eigene Wehr. Nach fuenf Phasen standen in der Beispielwehr 120 Atemschutz-Ueberwachungen statt einer und 115 Geraete statt 45; bei jedem Lauf aenderten sich alle Bildschirmfotos. Der Bestand ist einmal zurueckgesetzt, npm run db:test-reset baut die Testwehr neu auf. Behoben nebenbei: - Die Fehlerflaeche der Karte konnte nie etwas anzeigen. - Zwei useMemo der Lagekarte rechneten bei jedem Rendern neu. - tests/e2e war vom Typecheck ausgenommen und liess einen doppelt deklarierten Bezeichner durch. Closes #10 Closes #11 Closes #12 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
347 lines
12 KiB
TypeScript
347 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");
|
|
|
|
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();
|
|
});
|
|
});
|