Phase 3: Einsatz- und Uebungserfassung

Schnellerfassung im Alarmfall, sechs Zeitstempel mit Ausrueckzeit, Anfahrt
und Hilfsfrist, Staerke nach FwDV 3, Personalvorschlag mit
Qualifikationsstand, Einsatzbericht als PDF, Jahresexport fuer die Gemeinde,
Statistik und Uebungen mit Anwesenheitsliste.

Dazu das Einsatzportal fuer Fremdwehren: ein signierter Link ohne Anmeldung,
zeitlich und mengenbegrenzt, der Stichwort, Ort, Zeiten und Lagemeldungen
zeigt und Meldungen mit Foto annimmt. Es sind die ersten oeffentlich
erreichbaren Endpunkte in FLORIAN.

Acht neue Tabellen, alle mit organisationId und Row Level Security. Ein
freigegebener Einsatzbericht ist unveraenderlich; ein Datenbanktrigger setzt
das durch. Koordinaten liegen doppelt — als Dezimalgrad und als
PostGIS-Punkt, zusammengehalten von einem Trigger.

57 neue Tests (32 Unit, 14 gegen eine echte Datenbank, 11 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 13:50:58 +02:00
co-authored by Claude Opus 5
parent bc62a9c1e8
commit 91954c402b
100 changed files with 9667 additions and 38 deletions
+195
View File
@@ -0,0 +1,195 @@
import { expect, test, type Page } from "@playwright/test";
/**
* E2E-Tests für Phase 3.
*
* Geprüft wird der Weg vom Alarm bis zur Auswertung: schnell erfassen,
* Zeitstempel setzen, Mannschaft eintragen, melden — und am Ende die Zahlen,
* die die Gemeinde sehen will.
*
* Dazu der Portal-Link: er ist der einzige Weg in FLORIAN hinein, der ohne
* Anmeldung funktioniert, und wird deshalb ausdrücklich in einem Browser
* **ohne** Sitzung geprüft.
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
*/
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
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.beforeEach(async ({ page }) => {
await anmelden(page);
});
test.describe("Einsatzliste", () => {
test("zeigt die Einsätze der Wehr", async ({ page }) => {
await page.goto("/einsaetze");
await expect(page.getByRole("heading", { name: "Einsätze" })).toBeVisible();
// Aus dem Seed: acht Einsätze des laufenden Jahres.
await expect(page.getByRole("link", { name: /\d{4}-\d{4}/ }).first()).toBeVisible();
await expect(page.getByText("B 3 Wohnhausbrand")).toBeVisible();
});
test("filtert über die Suche", async ({ page }) => {
await page.goto("/einsaetze");
await page.getByLabel("Einsätze durchsuchen").fill("Ölspur");
await expect(page.getByText("THL 1 Ölspur")).toBeVisible();
await expect(page.getByText("B 3 Wohnhausbrand")).toHaveCount(0);
});
});
/**
* Nacheinander: die Tests bauen aufeinander auf — erst wird ein Einsatz
* erfasst, dann daran weitergearbeitet.
*/
test.describe.configure({ mode: "serial" });
test.describe("Einsatz erfassen und bearbeiten", () => {
/** Merkt sich die Adresse des in diesem Durchlauf angelegten Einsatzes. */
let einsatzAdresse = "";
test("legt einen Einsatz über die Schnellerfassung an", async ({ page }) => {
await page.goto("/einsaetze");
await page.getByRole("button", { name: "Einsatz erfassen" }).click();
await page
.getByLabel("Einsatzstichwort")
.fill(`THL 1 Testeinsatz ${test.info().project.name}`);
await page.getByLabel("Straße").fill("Teststraße");
await page.getByLabel("Ort", { exact: true }).fill("Musterhausen");
await page.getByRole("button", { name: "Einsatz anlegen" }).click();
// Danach steht die Detailseite offen.
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
einsatzAdresse = page.url();
await expect(page.getByRole("heading", { name: /THL 1 Testeinsatz/ })).toBeVisible();
await expect(page.getByText("läuft")).toBeVisible();
});
test("setzt Zeitstempel und rechnet die Kennzahlen", async ({ page }) => {
await page.goto(einsatzAdresse);
await expect(page.getByRole("heading", { name: "Zeiten" })).toBeVisible();
await page.getByRole("button", { name: "ausgerückt jetzt" }).click();
await expect(page.getByRole("button", { name: "ausgerückt zurücknehmen" })).toBeVisible();
await page.getByRole("button", { name: "eingetroffen jetzt" }).click();
await expect(page.getByRole("button", { name: "eingetroffen zurücknehmen" })).toBeVisible();
// Die Hilfsfrist steht danach da — auch wenn sie null Minuten beträgt.
await expect(page.getByText("Hilfsfrist")).toBeVisible();
});
test("erfasst Mannschaft über den Vorschlag und zeigt die Stärke", async ({ page }) => {
await page.goto(einsatzAdresse);
await page.getByRole("button", { name: "Mannschaft erfassen" }).click();
await expect(page.getByText(/sortiert nach Einsätzen im letzten Jahr/)).toBeVisible();
// Die ersten drei Vorschläge übernehmen.
const vorschlaege = page.locator("button", { hasText: /\d+ Einsätze/ });
await vorschlaege.nth(0).click();
await expect(page.getByText(/Kräfte erfasst/)).toBeVisible();
// Die Stärke wird sofort angezeigt.
await expect(page.getByText(/^\d+\/\d+\/\d+\/\d+$/)).toBeVisible();
});
test("nimmt eine Lagemeldung auf", async ({ page }) => {
await page.goto(einsatzAdresse);
await page.getByLabel("Neue Lagemeldung").fill("Lage erkundet, keine Gefahr für Personen.");
await page.getByRole("button", { name: "Melden" }).click();
await expect(page.getByText("Lage erkundet, keine Gefahr für Personen.")).toBeVisible();
});
test("gibt einen Portal-Link aus, der ohne Anmeldung funktioniert", async ({ page, browser }) => {
await page.goto(einsatzAdresse);
await page.getByLabel("Für wen ist der Link?").fill("FF Nachbarhausen");
await page.getByRole("button", { name: "Erzeugen" }).click();
const linkfeld = page.locator('input[readonly]');
await expect(linkfeld).toBeVisible();
const adresse = await linkfeld.inputValue();
expect(adresse).toContain("/portal/");
// Jetzt ein Browser ohne Sitzung — genau so, wie ihn eine Fremdwehr hätte.
const fremd = await browser.newContext();
const fremdeSeite = await fremd.newPage();
await fremdeSeite.goto(adresse);
await expect(fremdeSeite.getByRole("heading", { name: /Testeinsatz/ })).toBeVisible();
await expect(fremdeSeite.getByText("Zugang für FF Nachbarhausen")).toBeVisible();
// Und eine Meldung absetzen.
await fremdeSeite.getByLabel("Wer meldet?").fill("Gruppenführer Huber");
await fremdeSeite.getByLabel("Meldung").fill("Wir sind mit einer Staffel eingetroffen.");
await fremdeSeite.getByRole("button", { name: "Melden" }).click();
await expect(fremdeSeite.getByText("Wir sind mit einer Staffel eingetroffen.")).toBeVisible();
// Kein Weg in den Rest der Anwendung.
await expect(fremdeSeite.getByRole("link", { name: "Geräte" })).toHaveCount(0);
await fremd.close();
});
test("weist einen erfundenen Portal-Link ab", async ({ browser }) => {
const fremd = await browser.newContext();
const seite = await fremd.newPage();
await seite.goto("/portal/3f2504e0-4f89-11d3-9a0c-0305e82c3301.abcdefghijklmnopqrstuvwxyz01");
await expect(seite.getByTestId("portalfehler")).toBeVisible();
await expect(seite.getByText(/nicht gültig/)).toBeVisible();
await fremd.close();
});
});
test.describe("Statistik", () => {
test("zeigt Hilfsfrist, Kategorien und Beteiligung", async ({ page }) => {
await page.goto("/einsaetze/statistik");
await expect(page.getByRole("heading", { name: "Einsatzstatistik" })).toBeVisible();
await expect(page.getByText("Hilfsfrist (80 %)")).toBeVisible();
await expect(page.getByRole("heading", { name: "Nach Kategorie" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Beteiligung" })).toBeVisible();
// Aus dem Seed: die Kategorien der acht Einsätze.
await expect(page.getByText("Brand groß").first()).toBeVisible();
});
});
test.describe("Übungen", () => {
test("zeigt die Übungen und ihre Teilnehmerzahl", async ({ page }) => {
await page.goto("/uebungen");
await expect(page.getByRole("heading", { name: "Übungen" })).toBeVisible();
await expect(page.getByText("Innenangriff mit Atemschutz")).toBeVisible();
});
test("öffnet eine Übung und zeigt die Anwesenheitsliste", async ({ page }) => {
await page.goto("/uebungen");
await page.getByRole("link", { name: /\d{4}-\d{4}/ }).first().click();
await expect(page.getByRole("heading", { name: "Anwesenheit" })).toBeVisible();
await expect(page.getByText(/von \d+ aktiven Mitgliedern/)).toBeVisible();
});
});