Phase 7: Benachrichtigungen und Termine
Terminkalender mit Serienterminen, Selbstanmeldung, Warteliste und Anwesenheitsliste. Vier Zustellwege hinter Schnittstellen: echtes SMTP, ntfy (selbst gehostet), Telegram und Web-Push. Kalenderabonnement als ICS-Feed, digitaler Dienstausweis mit signiertem QR und Pruefseite. Niemand wird ungefragt benachrichtigt: ein neuer Zustellweg hat eine leere Ereignisliste, und der Seed legt bewusst keine an. Was das Netz der Wehr verlaesst, bekommt nur die Kurzfassung ohne Namen und Details. Kein CalDAV (Lesen genuegt, ICS kann jeder Kalender) und kein WhatsApp (Verarbeitung ueber Meta, Kosten je Konversation, Telefonbuchabgleich) -- beides im README begruendet. 56 neue Unit-Tests, 17 neue E2E-Tests, Version 0.8.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c779266450
commit
0b8874da26
@@ -72,14 +72,17 @@ test.describe("Anmeldung", () => {
|
||||
const navigation = page.getByRole("navigation", { name: "Hauptnavigation" });
|
||||
await expect(navigation.getByRole("link", { name: "Übersicht" })).toBeVisible();
|
||||
|
||||
// Die Karte steht seit Phase 6 zur Verfügung und ist ein Verweis.
|
||||
await expect(navigation.getByRole("link", { name: "Karte" })).toBeVisible();
|
||||
// Termine, Dienstausweis und Benachrichtigungen stehen seit Phase 7 zur
|
||||
// Verfügung und sind Verweise.
|
||||
await expect(navigation.getByRole("link", { name: "Termine" })).toBeVisible();
|
||||
await expect(navigation.getByRole("link", { name: "Dienstausweis" })).toBeVisible();
|
||||
await expect(navigation.getByRole("link", { name: "Benachrichtigungen" })).toBeVisible();
|
||||
|
||||
// Die Termine kommen erst in Phase 7 — der Eintrag ist sichtbar, aber kein
|
||||
// Die Kasse kommt erst in Phase 9 — der Eintrag ist sichtbar, aber kein
|
||||
// Verweis. Beim Abschluss einer Phase wandert diese Zusicherung eine Zeile
|
||||
// weiter nach unten.
|
||||
await expect(navigation.getByText("Termine")).toBeVisible();
|
||||
await expect(navigation.getByRole("link", { name: "Termine" })).toHaveCount(0);
|
||||
await expect(navigation.getByText("Kasse")).toBeVisible();
|
||||
await expect(navigation.getByRole("link", { name: "Kasse" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* 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" });
|
||||
|
||||
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
|
||||
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
|
||||
|
||||
/** Eindeutiger Titel je Lauf, damit sich parallele Läufe nicht ins Gehege kommen. */
|
||||
function eigenerTitel(zweck: string): string {
|
||||
return `E2E-${zweck}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
async function anmelden(page: Page): Promise<void> {
|
||||
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("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 = eigenerTitel("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(eigenerTitel("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 = eigenerTitel("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(eigenerTitel("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(eigenerTitel("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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user