Phase 10: Mandantenfaehigkeit

Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehoerigkeiten. Eine
Kreisebene, die zusieht.

Datenmodell und RLS trugen seit Phase 0 mehrere Wehren. Was fehlte, war
alles darueber: bis hierher war die Personenakte zugleich das Anmeldekonto,
und die gehoert genau einer Wehr. Wer in zweien gefuehrt wurde, brauchte
zwei Passwoerter, und die Anmeldung per E-Mail war mehrdeutig.

Neu sind `Benutzer` (das Konto) und `Mitgliedschaft` (die Zugehoerigkeit).
Das Konto traegt bewusst keine Mandantenkennung und kann keine tragen - es
ist der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehoert. Geschuetzt
wird es deshalb ueber Rechte statt ueber RLS, wie `Berechtigung` seit Phase
0. Was es in einer Wehr darf, entscheidet seine Mitgliedschaft.

Die Sitzung behaelt ihre Form und entsteht nur anders - aus der aktiven
Mitgliedschaft statt aus der Person. Von 414 Fundstellen in 61 Dateien
musste dadurch keine angefasst werden. Die eine Ausnahme ist Absicht:
`personId` ist nullbar geworden, weil eine Aufsichtssitzung keine Akte hat,
und der Typechecker hat daraus genau die fuenf Stellen gemacht, an denen
die Kreisaufsicht nichts verloren hat.

Ihre Rechte werden gefiltert statt aufgezaehlt: alles, was `.lesen` heisst,
minus Kasse, Audit-Log, KI-Protokoll und Personenexport. So bekommt sie
neue Leserechte automatisch und neue Schreibrechte nie.

Die Migration ist datenerhaltend und bricht ab, statt einen halben Zustand
zu hinterlassen.

Nebenbei behoben: `db:test-reset` liess die Nachbarwehr stehen, der
Fehlversuch-Test sperrte das gemeinsame Testkonto, ein Testaufbau war
nicht wiederholbar, und die Wartelogik der Bildschirmfotos konnte
ausloesen, bevor eine Seite ueberhaupt geladen war.

581 Unit-Tests und 266 E2E-Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-05 13:43:24 +02:00
co-authored by Claude Opus 5
parent 2b53556175
commit ff40437a1a
98 changed files with 3810 additions and 201 deletions
+241
View File
@@ -0,0 +1,241 @@
import { expect, test } from "@playwright/test";
import { ADMIN_EMAIL, ADMIN_PASSWORT, anmelden } from "./wehr";
/**
* E2E-Tests für Phase 10 — Mandantenfähigkeit.
*
* Geprüft wird das, wovon die Trennung abhängt:
*
* * wer nur zu einer Wehr gehört, sieht keinen Wähler
* * wer zu mehreren gehört, landet in seiner eigenen und kann wechseln
* * nach dem Wechsel stehen die Daten der anderen Wehr da, nicht die alten
* * als Kreisaufsicht steht der Hinweis, und es gibt nichts zu ändern
* * die Kasse der betreuten Wehr bleibt zu
*
* Voraussetzung: `npm run db:test-reset`. Der Seed legt dabei die Testwehr,
* eine Nachbarwehr und ein Konto an, das in der einen Mitglied und in der
* anderen Aufsicht ist.
*/
test.describe.configure({ mode: "serial" });
/** Das Konto der Kreisebene — Mitglied hier, Aufsicht nebenan. */
const KBI_EMAIL = process.env.E2E_KBI_EMAIL ?? "kbi@ff-teststelle.de";
const KBI_PASSWORT = process.env.E2E_KBI_PASSWORT ?? "Feuerwehr2026";
/**
* Öffnet die Seitenleiste, falls sie hinter dem Menüknopf steckt.
*
* Auf dem Tablet (Galaxy Tab S4 quer, 1138 px) liegt die Leiste unterhalb der
* `md`-Grenze und ist eingeklappt. Der Wehrwähler sitzt darin — wer ihn dort
* sucht, ohne vorher aufzuklappen, wartet vergeblich.
*/
async function seitenleisteOeffnen(page: import("@playwright/test").Page) {
const knopf = page.getByRole("button", { name: "Navigation öffnen" });
if (await knopf.isVisible()) await knopf.click();
}
/** Meldet ein beliebiges Konto an. */
async function anmeldenAls(page: import("@playwright/test").Page, email: string, passwort: string) {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(email);
await page.getByLabel("Passwort").fill(passwort);
await page.getByRole("button", { name: "Anmelden" }).click();
await expect(page, `Anmeldung als ${email} fehlgeschlagen.`).toHaveURL("/");
}
test.describe("Ein Konto, eine Wehr", () => {
test("zeigt keinen Wehrwähler, wenn es nichts zu wählen gibt", async ({ page }) => {
await anmelden(page);
// Der Name der Wehr steht da — aber nicht als Auswahl. Ein Auswahlfeld mit
// genau einem Eintrag ist eine Frage ohne Antwortmöglichkeit.
await expect(page.getByRole("button", { name: /Wehr wechseln/ })).toHaveCount(0);
await expect(page.getByRole("listbox", { name: "Wehr wechseln" })).toHaveCount(0);
});
test("sieht keinen Aufsichtshinweis", async ({ page }) => {
await anmelden(page);
await expect(page.getByText("Sie sehen diese Wehr als")).toHaveCount(0);
});
});
test.describe("Ein Konto, zwei Wehren", () => {
test("landet nach der Anmeldung in der eigenen Wehr, nicht in der betreuten", async ({
page,
}) => {
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
// Die eigene vor der betreuten: wer eine Wehr führt und eine zweite
// beaufsichtigt, will in seiner landen.
await expect(page.getByText("Sie sehen diese Wehr als")).toHaveCount(0);
});
test("bietet den Wehrwähler an", async ({ page }) => {
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
await seitenleisteOeffnen(page);
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
const liste = page.getByRole("listbox", { name: "Wehr wechseln" });
await expect(liste).toBeVisible();
await expect(liste.getByRole("option")).toHaveCount(2);
// Beide Arten stehen mit Klartext da — nicht nur mit einem Symbol.
await expect(liste.getByText("Mitglied", { exact: true })).toBeVisible();
await expect(liste.getByText("Aufsicht — nur lesen")).toBeVisible();
});
test("wechselt in die betreute Wehr und sagt, dass dort nichts geht", async ({ page }) => {
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
await seitenleisteOeffnen(page);
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
await page
.getByRole("listbox", { name: "Wehr wechseln" })
.getByRole("option")
.filter({ hasText: "Aufsicht" })
.click();
// Der Hinweis steht dauerhaft, nicht als kurz aufblitzende Meldung.
await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible();
await expect(page.getByRole("note").filter({ hasText: "Kreisaufsicht" })).toBeVisible();
});
test("zeigt nach dem Wechsel die Mitglieder der anderen Wehr", async ({ page }) => {
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
await seitenleisteOeffnen(page);
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
await page
.getByRole("listbox", { name: "Wehr wechseln" })
.getByRole("option")
.filter({ hasText: "Aufsicht" })
.click();
await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible();
await page.goto("/mitglieder");
// Die drei aus der Nachbarwehr — und nicht die fünfzehn der eigenen.
await expect(page.getByRole("cell", { name: /Sailer/ })).toBeVisible();
await expect(page.getByRole("cell", { name: /Ostermeier/ })).toBeVisible();
// Die Kreisaufsicht selbst steht in keiner Mitgliederliste: sie gehört
// der Wehr nicht an und hat dort keine Akte.
await expect(page.getByRole("cell", { name: /Huber/ })).toHaveCount(0);
});
test("lässt die Kreisaufsicht in der betreuten Wehr nichts anlegen", async ({ page }) => {
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
await seitenleisteOeffnen(page);
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
await page
.getByRole("listbox", { name: "Wehr wechseln" })
.getByRole("option")
.filter({ hasText: "Aufsicht" })
.click();
await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible();
await page.goto("/mitglieder");
// Kein Knopf, der ins Leere führt. Das Recht fehlt serverseitig ohnehin —
// die Oberfläche soll es aber gar nicht erst anbieten.
await expect(page.getByRole("button", { name: /Mitglied anlegen/ })).toHaveCount(0);
});
test("hält die Kasse der betreuten Wehr geschlossen", async ({ page }) => {
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
await seitenleisteOeffnen(page);
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
await page
.getByRole("listbox", { name: "Wehr wechseln" })
.getByRole("option")
.filter({ hasText: "Aufsicht" })
.click();
await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible();
// Sie gehört dem Verein oder der Kommune, nicht der Kreisaufsicht.
await seitenleisteOeffnen(page);
await expect(page.locator("aside").getByRole("link", { name: "Kasse" })).toHaveCount(0);
await page.goto("/kasse");
await expect(page).not.toHaveURL(/\/kasse$/);
});
test("kommt in die eigene Wehr zurück", async ({ page }) => {
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
await seitenleisteOeffnen(page);
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
await page
.getByRole("listbox", { name: "Wehr wechseln" })
.getByRole("option")
.filter({ hasText: "Aufsicht" })
.click();
await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible();
await seitenleisteOeffnen(page);
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
await page
.getByRole("listbox", { name: "Wehr wechseln" })
.getByRole("option")
.filter({ hasText: "Mitglied" })
.click();
await expect(page.getByText("Sie sehen diese Wehr als")).toHaveCount(0);
await seitenleisteOeffnen(page);
await expect(page.locator("aside").getByRole("link", { name: "Kasse" })).toBeVisible();
});
});
test.describe("Der Administrator", () => {
test("bleibt nach der Umstellung anmeldbar", async ({ page }) => {
// Die Migration hat sein Konto aus der Personenakte gelöst. Wäre dabei
// etwas verlorengegangen, käme er hier nicht mehr hinein — und niemand
// sonst auch.
await anmeldenAls(page, ADMIN_EMAIL, ADMIN_PASSWORT);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
});
});
test.describe("Wehren und Konten", () => {
test("steht nur dem Verwalter offen", async ({ page }) => {
// Der KBI führt seine Wehr, verwaltet aber nicht die Installation.
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
await seitenleisteOeffnen(page);
await expect(
page.locator("aside").getByRole("link", { name: "Wehren und Konten" }),
).toHaveCount(0);
// Und die Seite selbst schickt ihn weg, nicht nur das Menü.
await page.goto("/wehren");
await expect(page).not.toHaveURL(/\/wehren$/);
});
test("zeigt dem Verwalter die Wehren dieser Installation", async ({ page }) => {
await anmeldenAls(page, ADMIN_EMAIL, ADMIN_PASSWORT);
await seitenleisteOeffnen(page);
await page.locator("aside").getByRole("link", { name: "Wehren und Konten" }).click();
await expect(page.getByRole("heading", { name: "Wehren und Konten", level: 1 })).toBeVisible();
// Die Testwehr und ihre Nachbarwehr aus dem Seed. Der Verwalter sieht
// bewusst **alle** Wehren der Installation — auch die einer anderen
// Kommune, wenn sie auf demselben Server liegt. Das ist der Unterschied
// zwischen dem Betreiber und einer Wehr.
await expect(page.getByRole("heading", { name: "FF Teststelle Nachbar" })).toBeVisible();
await expect(page.getByRole("heading", { name: "FF Teststelle", exact: true })).toBeVisible();
});
test("zeigt, welches Konto zu welchen Wehren gehört", async ({ page }) => {
await anmeldenAls(page, ADMIN_EMAIL, ADMIN_PASSWORT);
await page.goto("/wehren");
// Das Konto der Kreisebene steht mit beiden Zuordnungen da — und die
// Aufsicht ist als solche gekennzeichnet, nicht als Mitgliedschaft.
await expect(page.getByText(KBI_EMAIL)).toBeVisible();
await expect(page.getByText("Kreisaufsicht — nur lesen").first()).toBeVisible();
});
});