feat(mandant): Betriebsmodus, Kundenverwaltung und Einstellungen (#22)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Zwei Betriebsmodi, ein Codepfad. src/server/mandant.ts ist die eine Stelle,
an der die Mandantenregel steht.

Der Review fand drei Blocker: die Lesemodelle wandten die Mandantensicht nicht
an, der Protokolleintrag des Moduswechsels stand außerhalb der Transaktion, und
Prüfung und Vollzug lagen so weit auseinander, dass zwei gleichzeitige Wechsel
beide durchliefen. Alle drei geschlossen, der letzte mit einem Advisory Lock.

336 Tests.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #22.
This commit is contained in:
2026-09-25 05:03:11 +00:00
co-authored by Claude Opus 5
parent 40e95dbe5f
commit ecc3c579df
43 changed files with 2729 additions and 53 deletions
+35 -2
View File
@@ -15,7 +15,7 @@
*/
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { chromium, type Browser, type Cookie } from "@playwright/test";
import { chromium, type Browser, type Cookie, type Page } from "@playwright/test";
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
@@ -29,13 +29,41 @@ type Seite = {
readonly pfad: string;
/** Oeffentliche Seiten werden ohne Anmeldung aufgenommen. */
readonly ohneAnmeldung?: boolean;
/**
* Nur aufnehmen, wenn der Betriebsmodus passt. Der Modus steht in der
* Datenbank; wer beide Seiten braucht, laesst das Skript zweimal laufen und
* stellt dazwischen um.
*/
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
/** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */
readonly vorbereiten?: (tab: Page) => Promise<void>;
};
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
const SEITEN: readonly Seite[] = [
{ name: "anmelden", pfad: "/anmelden", ohneAnmeldung: true },
{ name: "uebersicht", pfad: "/" },
{ name: "admin-einstellungen", pfad: "/admin" },
{
name: "admin-moduswechsel",
pfad: "/admin",
nurModus: "INTERN",
vorbereiten: async (tab) => {
// Gezielt den Wechsel-Dialog aufklappen. `getByRole("group").first()`
// traf bei 390 px das Navigationsmenue, weil das dort das erste details ist.
await tab.locator("summary", { hasText: "wechseln" }).click();
await tab.waitForTimeout(150);
},
},
{
// Mit mehr als einem nicht archivierten Kunden ist der Rueckweg gesperrt.
// Genau das soll die Anleitung zeigen.
name: "admin-moduswechsel-abgelehnt",
pfad: "/admin",
nurModus: "DIENSTLEISTUNG",
},
{ name: "admin-benutzer", pfad: "/admin/benutzer" },
{ name: "kunden", pfad: "/kunden", nurModus: "DIENSTLEISTUNG" },
];
function argument(name: string): string | undefined {
@@ -101,6 +129,8 @@ async function kachelnErzeugen(browser: Browser): Promise<void> {
const zielMeilenstein = argument("ziel");
const nur = argument("nur");
/** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */
const modus = (argument("modus") ?? "INTERN") as "INTERN" | "DIENSTLEISTUNG";
const ziel = zielMeilenstein
? join("docs", "screenshots", zielMeilenstein)
: join("docs", "anleitung", "bilder");
@@ -112,7 +142,9 @@ const browser = await chromium.launch();
try {
await kachelnErzeugen(browser);
const auswahl = SEITEN.filter((s) => !nur || s.name === nur);
const auswahl = SEITEN.filter(
(s) => (!nur || s.name === nur) && (s.nurModus === undefined || s.nurModus === modus),
);
const kekse = auswahl.some((s) => !s.ohneAnmeldung) ? await anmelden(browser) : [];
for (const seite of auswahl) {
@@ -127,6 +159,7 @@ try {
if (!seite.ohneAnmeldung) await kontext.addCookies(kekse);
const tab = await kontext.newPage();
await tab.goto(`${APP_URL}${seite.pfad}`, { waitUntil: "load" });
if (seite.vorbereiten) await seite.vorbereiten(tab);
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
await tab.evaluate(() => document.fonts.ready);
await tab.waitForLoadState("domcontentloaded");