From e0cbf4b8dec56fd0b25018061e0b1107beda4639 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 4 Sep 2026 18:08:08 +0200 Subject: [PATCH] =?UTF-8?q?Playwright-E2E-Tests=20f=C3=BCr=20die=20PWA=20(?= =?UTF-8?q?Login,=20Sperrlogik,=20Fortschritt,=20Offline-Queue)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Läuft komplett gegen gemockten Backend (e2e/mockApi.ts, page.route) - kein echtes Backend/DB nötig, playwright.config.ts startet npm run dev automatisch. Deckt Login-Erfolg/-Fehler, 409-Objektsperre mit Übernahme, hochzählende Fortschrittsanzeige und Offline-Queue-Verhalten (kein Request während offline, Sync bei Reconnect) ab. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- DEVLOG.md | 22 +++++++ frontend/.gitignore | 3 + frontend/DEVLOG.md | 22 +++++++ frontend/README.md | 18 ++++-- frontend/e2e/fortschritt.spec.ts | 41 +++++++++++++ frontend/e2e/login.spec.ts | 30 +++++++++ frontend/e2e/mockApi.ts | 98 ++++++++++++++++++++++++++++++ frontend/e2e/offline-queue.spec.ts | 65 ++++++++++++++++++++ frontend/e2e/sperrlogik.spec.ts | 34 +++++++++++ frontend/package.json | 5 +- frontend/playwright.config.ts | 21 +++++++ 11 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 frontend/e2e/fortschritt.spec.ts create mode 100644 frontend/e2e/login.spec.ts create mode 100644 frontend/e2e/mockApi.ts create mode 100644 frontend/e2e/offline-queue.spec.ts create mode 100644 frontend/e2e/sperrlogik.spec.ts create mode 100644 frontend/playwright.config.ts diff --git a/DEVLOG.md b/DEVLOG.md index 7003b35..340b4cc 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2668,3 +2668,25 @@ Keine Commits in dieser Session. - backend/scripts/test_label_erzeugen.py | 34 ++++++++++++++++++++++++++++++++++ --- +## 2026-09-04 18:04 – 18:04 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** backend + +### Commits +- fb676a4 Karte 12: Eskalationslogik für lange offene Fehlbestände + +### Geänderte Dateien +- DEVLOG.md | 13 +++ +- arbeitskarten/12_eskalation.md | 5 ++ +- backend/DEVLOG.md | 13 +++ +- .../alembic/versions/0006_add_eskalation_felder.py | 50 +++++++++++ +- backend/app/api/v1/api.py | 2 + +- backend/app/api/v1/endpoints/eskalation.py | 47 +++++++++++ +- backend/app/models/__init__.py | 2 + +- backend/app/models/eskalation.py | 16 ++++ +- backend/app/models/fehlbestand.py | 4 + +- backend/app/schemas/eskalation.py | 17 ++++ +- backend/app/services/eskalation.py | 98 ++++++++++++++++++++++ +- backend/tests/test_eskalation.py | 73 ++++++++++++++++ + +--- diff --git a/frontend/.gitignore b/frontend/.gitignore index 9f7f96d..2f8bcef 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -3,3 +3,6 @@ dist/ dev-dist/ .env .env.local +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/frontend/DEVLOG.md b/frontend/DEVLOG.md index f56f3b8..17f4f49 100644 --- a/frontend/DEVLOG.md +++ b/frontend/DEVLOG.md @@ -31,3 +31,25 @@ Keine Commits in dieser Session. - frontend/src/pages/admin/ObjektSection.tsx | 58 +++++++++++++++++++++++++++++- --- +## 2026-09-04 18:04 – 18:07 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 13 +++ +- arbeitskarten/12_eskalation.md | 5 ++ +- backend/DEVLOG.md | 13 +++ +- .../alembic/versions/0006_add_eskalation_felder.py | 50 +++++++++++ +- backend/app/api/v1/api.py | 2 + +- backend/app/api/v1/endpoints/eskalation.py | 47 +++++++++++ +- backend/app/models/__init__.py | 2 + +- backend/app/models/eskalation.py | 16 ++++ +- backend/app/models/fehlbestand.py | 4 + +- backend/app/schemas/eskalation.py | 17 ++++ +- backend/app/services/eskalation.py | 98 ++++++++++++++++++++++ +- backend/tests/test_eskalation.py | 73 ++++++++++++++++ + +--- diff --git a/frontend/README.md b/frontend/README.md index e32a4f5..9d7d27b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -36,9 +36,17 @@ wird vom Reverse-Proxy (nginx) ausgeliefert, siehe `deploy/`. - Client-Fehler (4xx, z. B. Objekt-Sperre durch Übernahme) werden NICHT automatisch wiederholt - der Mitarbeiter sieht den Fehler und muss bewusst reagieren. -## Noch nicht umgesetzt (bewusst außerhalb Sprint 7) +## E2E-Tests (Playwright) -- QR-/Barcode-Kamera-Scan (Karte 10, Roadmap) - Objektsuche ist vorbereitet (Textfeld - akzeptiert auch einen gescannten Code), aber kein Kamera-Zugriff verdrahtet. -- Playwright-E2E-Tests und Praktiker-Explorations-Session (Testphase 4) - dafür wird ein - laufendes Test-Deployment gebraucht, kein reiner Code-Schritt. +```bash +npm install +npx playwright install chromium +npm run test:e2e +``` + +Läuft komplett gegen einen gemockten Backend (`e2e/mockApi.ts`, `page.route`) - kein +echtes Backend/DB nötig, `playwright.config.ts` startet `npm run dev` automatisch. +Deckt Login, Objekt-Sperre/Übernahme, Fortschrittsanzeige und die Offline-Queue +(Reconnect-Verhalten) ab. Reale Integrationstests gegen ein echtes Backend sowie die +Praktiker-Explorations-Session (Testphase 4) sind ein separater, späterer Schritt - +dafür wird ein laufendes Test-Deployment gebraucht, kein reiner Code-Schritt. diff --git a/frontend/e2e/fortschritt.spec.ts b/frontend/e2e/fortschritt.spec.ts new file mode 100644 index 0000000..bcbeea4 --- /dev/null +++ b/frontend/e2e/fortschritt.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test"; + +import { + login, + mockKontrolleStart, + mockKontrollGrundgeruest, + mockLogin, + TEST_KONTROLLE, + TEST_OBJEKT, + TEST_POSITION, +} from "./mockApi"; + +test("Fortschrittsanzeige zählt bestätigte Positionen hoch", async ({ page }) => { + await mockLogin(page); + await mockKontrollGrundgeruest(page); + await mockKontrolleStart(page); + await page.route(`**/api/v1/kontrollen/${TEST_KONTROLLE.id}/positionen/${TEST_POSITION.material_id}`, (route) => + route.fulfill({ + json: { + id: "kp-1", + kontrolle_id: TEST_KONTROLLE.id, + material_id: TEST_POSITION.material_id, + sollmenge_snapshot: TEST_POSITION.sollmenge_effektiv, + istmenge_erfasst: TEST_POSITION.sollmenge_effektiv, + abweichung: false, + fehlbestand_id: null, + }, + }) + ); + + await login(page); + await page.goto(`/objekte/${TEST_OBJEKT.id}/kontrolle`); + + await expect(page.getByText("0 von 1 Positionen bestätigt")).toBeVisible(); + + // Eingabe entspricht bereits der Sollmenge -> Button zeigt "Passt". + await page.getByRole("button", { name: "Passt" }).click(); + + await expect(page.getByText("1 von 1 Positionen bestätigt")).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole("button", { name: "Kontrolle abschließen" })).toBeEnabled(); +}); diff --git a/frontend/e2e/login.spec.ts b/frontend/e2e/login.spec.ts new file mode 100644 index 0000000..bcdd021 --- /dev/null +++ b/frontend/e2e/login.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test"; + +import { mockLogin, mockObjektliste } from "./mockApi"; + +test("erfolgreicher Login führt zur Objektliste", async ({ page }) => { + await mockLogin(page); + await mockObjektliste(page); + + await page.goto("/login"); + await page.getByLabel("Login").fill("mitarbeiter1"); + await page.getByLabel("Passwort").fill("test-passwort-123"); + await page.getByRole("button", { name: "Anmelden" }).click(); + + await expect(page).toHaveURL(/\/objekte$/); + await expect(page.getByRole("heading", { name: "Objekte" })).toBeVisible(); +}); + +test("falsches Passwort zeigt Fehlermeldung, bleibt auf Login", async ({ page }) => { + await page.route("**/api/v1/auth/login", (route) => + route.fulfill({ status: 401, json: { detail: "Login oder Passwort falsch" } }) + ); + + await page.goto("/login"); + await page.getByLabel("Login").fill("mitarbeiter1"); + await page.getByLabel("Passwort").fill("falsch"); + await page.getByRole("button", { name: "Anmelden" }).click(); + + await expect(page.getByText("Login oder Passwort falsch")).toBeVisible(); + await expect(page).toHaveURL(/\/login$/); +}); diff --git a/frontend/e2e/mockApi.ts b/frontend/e2e/mockApi.ts new file mode 100644 index 0000000..33193c3 --- /dev/null +++ b/frontend/e2e/mockApi.ts @@ -0,0 +1,98 @@ +import type { Page } from "@playwright/test"; + +/** + * Gemeinsamer Backend-Mock für alle Kontroll-/Login-Szenarien. Jeder Test ruft + * nur die Bausteine auf, die er tatsächlich braucht - `mockGrundgeruest` + * deckt Login+Stammdaten ab, die Kontroll-Endpunkte werden gezielt pro Test + * überschrieben (z. B. für Sperre/Fehler-Szenarien). + */ +export const TEST_OBJEKT = { + id: 1, + code: "RK-00001", + name: "Testrucksack", + objekttyp_id: 1, + vorlage_id: 1, + standort_id: 1, + status: "aktiv", +}; + +export const TEST_MATERIAL = { + id: 10, + name: "Kompresse", + artikelnummer: null, + einheit: "Stück", + materialtyp: "standard", + kategorie_id: null, + hersteller: null, + beschreibung: null, + code: null, + warnzeitraum_tage: null, + aktiv: true, +}; + +export const TEST_POSITION = { + id: "pos-1", + objekt_id: 1, + material_id: 10, + sollmenge_override: null, + sollmenge_effektiv: "10", + ist_status: "aktiv", + istmenge: "10", + seriennummer: null, + ablaufdatum: null, + chargennummer: null, +}; + +export const TEST_KONTROLLE = { + id: "kontrolle-1", + objekt_id: 1, + benutzer_id: 1, + status: "in_bearbeitung", + gestartet_am: new Date().toISOString(), + beendet_am: null, + abbruch_grund: null, +}; + +export async function mockLogin(page: Page, rollen: string[] = ["mitarbeiter"]): Promise { + await page.route("**/api/v1/auth/login", (route) => + route.fulfill({ json: { access_token: "test-token", token_type: "bearer" } }) + ); + await page.route("**/api/v1/auth/me", (route) => + route.fulfill({ json: { id: 1, name: "Test", login: "test", rollen } }) + ); +} + +export async function mockObjektliste(page: Page, objekte: unknown[] = [TEST_OBJEKT]): Promise { + await page.route("**/api/v1/objekte", (route) => { + if (route.request().method() === "GET") { + route.fulfill({ json: objekte }); + } else { + route.continue(); + } + }); +} + +export async function mockKontrolleStart( + page: Page, + options: { status?: number; body?: unknown } = {} +): Promise { + const { status = 201, body = TEST_KONTROLLE } = options; + await page.route("**/api/v1/objekte/*/kontrollen", (route) => + route.fulfill({ status, json: body }) + ); +} + +export async function mockKontrollGrundgeruest(page: Page): Promise { + await page.route(`**/api/v1/objekte/${TEST_OBJEKT.id}`, (route) => route.fulfill({ json: TEST_OBJEKT })); + await page.route(`**/api/v1/objekte/${TEST_OBJEKT.id}/positionen`, (route) => + route.fulfill({ json: [TEST_POSITION] }) + ); + await page.route("**/api/v1/materialien", (route) => route.fulfill({ json: [TEST_MATERIAL] })); +} + +export async function login(page: Page): Promise { + await page.goto("/login"); + await page.getByLabel("Login").fill("mitarbeiter1"); + await page.getByLabel("Passwort").fill("test-passwort-123"); + await page.getByRole("button", { name: "Anmelden" }).click(); +} diff --git a/frontend/e2e/offline-queue.spec.ts b/frontend/e2e/offline-queue.spec.ts new file mode 100644 index 0000000..204d7d4 --- /dev/null +++ b/frontend/e2e/offline-queue.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; + +import { + login, + mockKontrolleStart, + mockKontrollGrundgeruest, + mockLogin, + TEST_KONTROLLE, + TEST_OBJEKT, + TEST_POSITION, +} from "./mockApi"; + +test("Eingabe bleibt offline in der Warteschlange und wird bei Reconnect übertragen", async ({ + page, + context, +}) => { + await mockLogin(page); + await mockKontrollGrundgeruest(page); + await mockKontrolleStart(page); + + // context.setOffline() emuliert navigator.onLine/online-/offline-Events, ein + // per page.route gemocktes route.fulfill() würde das NICHT-Netzwerk-Verhalten + // (ERR_INTERNET_DISCONNECTED) ohne dieses manuelle Flag nicht nachbilden - hier + // bewusst per Hand simuliert, damit der Test die Queue-Logik echt prüft. + let offline = false; + let puts = 0; + await page.route(`**/api/v1/kontrollen/${TEST_KONTROLLE.id}/positionen/${TEST_POSITION.material_id}`, (route) => { + if (offline) { + route.abort("internetdisconnected"); + return; + } + puts += 1; + route.fulfill({ + json: { + id: "kp-1", + kontrolle_id: TEST_KONTROLLE.id, + material_id: TEST_POSITION.material_id, + sollmenge_snapshot: TEST_POSITION.sollmenge_effektiv, + istmenge_erfasst: TEST_POSITION.sollmenge_effektiv, + abweichung: false, + fehlbestand_id: null, + }, + }); + }); + + await login(page); + await page.goto(`/objekte/${TEST_OBJEKT.id}/kontrolle`); + await expect(page.getByText("0 von 1 Positionen bestätigt")).toBeVisible(); + + // Karte 17: navigator.onLine=false + offline-Event, IndexedDB-Queue bleibt + // trotzdem befüllbar - Eingabe darf hier NICHT verloren gehen. + offline = true; + await context.setOffline(true); + await expect(page.getByText(/Kein Netz/)).toBeVisible(); + + await page.getByRole("button", { name: "Passt" }).click(); + await expect(page.getByText("nicht gespeichert")).toBeVisible(); + expect(puts).toBe(0); // während offline darf kein Request erfolgreich rausgehen + + offline = false; + await context.setOffline(false); + + await expect(page.getByText("1 von 1 Positionen bestätigt")).toBeVisible({ timeout: 10000 }); + expect(puts).toBeGreaterThan(0); +}); diff --git a/frontend/e2e/sperrlogik.spec.ts b/frontend/e2e/sperrlogik.spec.ts new file mode 100644 index 0000000..9172139 --- /dev/null +++ b/frontend/e2e/sperrlogik.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; + +import { login, mockKontrollGrundgeruest, mockLogin, TEST_KONTROLLE, TEST_OBJEKT } from "./mockApi"; + +test("Objekt-Sperre zeigt Übernahme-Dialog, Übernehmen startet neue Kontrolle", async ({ page }) => { + await mockLogin(page); + await mockKontrollGrundgeruest(page); + + let uebernommen = false; + await page.route("**/api/v1/objekte/*/kontrollen", async (route) => { + const body = route.request().postDataJSON() as { uebernehmen: boolean }; + if (!body.uebernehmen) { + await route.fulfill({ + status: 409, + json: { benutzer: "Kollege Müller", seit: "vor 12 Minuten" }, + }); + return; + } + uebernommen = true; + await route.fulfill({ status: 201, json: TEST_KONTROLLE }); + }); + + await login(page); + await page.goto(`/objekte/${TEST_OBJEKT.id}/kontrolle`); + + await expect(page.getByRole("heading", { name: "Objekt gesperrt" })).toBeVisible(); + await expect(page.getByText("Kollege Müller")).toBeVisible(); + await expect(page.getByText("vor 12 Minuten")).toBeVisible(); + + await page.getByRole("button", { name: "Trotzdem übernehmen" }).click(); + + await expect(page.getByRole("heading", { name: "Objekt gesperrt" })).not.toBeVisible(); + expect(uebernommen).toBe(true); +}); diff --git a/frontend/package.json b/frontend/package.json index 93c418d..01d1df6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@zxing/browser": "^0.1.5", @@ -16,6 +18,7 @@ "react-router-dom": "^6.26.2" }, "devDependencies": { + "@playwright/test": "^1.47.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..24c822b --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Playwright-Tests laufen komplett gegen einen gemockten Backend (page.route, +// siehe e2e/mockApi.ts) - kein echter Server/DB nötig, damit sie überall lauffähig +// sind, wo `npm run dev` läuft. Reale Integrationstests gegen ein echtes Backend +// sind ein späterer, separater Schritt (anderer Zweck: Vertragstest statt UI-Test). +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + reporter: "list", + use: { + baseURL: "http://localhost:5173", + trace: "on-first-retry", + }, + webServer: { + command: "npm run dev", + url: "http://localhost:5173", + reuseExistingServer: !process.env.CI, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +});