Kein Modul, sondern die drei Software-Voraussetzungen fuer 1.0.0: Auskunft nach Art. 15 DSGVO, ein geprueftes Sicherungskonzept und eine Pipeline. Das Auskunftsrecht war ein Versprechen ohne Umsetzung: `person.export` stand seit Phase 0 im Katalog und war dem Kommandanten zugewiesen, implementiert war nichts. Ohne Auskunftsseite ist ein Loeschkonzept nicht abnehmbar. Jetzt sammelt FLORIAN dreizehn Abschnitte ueber eine Person, mit dem Zweck ueber jedem - Art. 15 verlangt nicht nur die Daten, sondern auch Zwecke und Rechte, und eine blosse Liste erfuellt die Pflicht nicht. Ein Durchgang liefert Anzeige, PDF und JSON nach Art. 20. Die Trennlinie ist "ueber mich" gegen "von mir": was ueber die Person gespeichert ist, steht vollstaendig drin, auch das Unangenehme. Was sie getan hat, steht als Zahl drin - die Pruefprotokolle gehoeren zum Nachweisbestand der Wehr, und vollstaendig ausgeliefert kaeme jeder ueber eine Selbstauskunft an die Geraeteakten. Die Rueckspielprobe hat sich sofort bezahlt gemacht: der erste Versuch lief sauber durch und hatte dabei saemtliche Unveraenderlichkeitsgarantien der Datenbank entfernt. Die Vorgabeprivilegierung aus dem Init-Skript gibt jeder von pg_restore neu angelegten Tabelle wieder alle Rechte, und GRANT kann nur hinzufuegen. Die Anwendungsrolle durfte danach das Audit-Log aendern, Wiki-Fassungen loeschen, Kiosk-Vorgaenge umschreiben - und nichts haette darauf hingewiesen. Gefunden haben es vier Datenbanktests unmittelbar nach der Probe, also genau die Probe, wegen der 1.0.0 sie verlangt. Die Pipeline ist geschrieben, aber NICHT erprobt: in Gitea ist kein act_runner registriert. Das steht so im README und im Devlog, damit sich niemand auf eine Pruefung verlaesst, die nicht stattfindet. 601 Unit-Tests gruen; Sicherung und Wiederherstellung einmal vollstaendig durchgefuehrt und die Tests danach erneut gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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();
|
|
});
|
|
});
|