Playwright-E2E-Tests für die PWA (Login, Sperrlogik, Fortschritt, Offline-Queue)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -2668,3 +2668,25 @@ Keine Commits in dieser Session.
|
|||||||
- backend/scripts/test_label_erzeugen.py | 34 ++++++++++++++++++++++++++++++++++
|
- 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 ++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ dist/
|
|||||||
dev-dist/
|
dev-dist/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
playwright/.cache/
|
||||||
|
|||||||
@@ -31,3 +31,25 @@ Keine Commits in dieser Session.
|
|||||||
- frontend/src/pages/admin/ObjektSection.tsx | 58 +++++++++++++++++++++++++++++-
|
- 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 ++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+13
-5
@@ -36,9 +36,17 @@ wird vom Reverse-Proxy (nginx) ausgeliefert, siehe `deploy/`.
|
|||||||
- Client-Fehler (4xx, z. B. Objekt-Sperre durch Übernahme) werden NICHT automatisch
|
- Client-Fehler (4xx, z. B. Objekt-Sperre durch Übernahme) werden NICHT automatisch
|
||||||
wiederholt - der Mitarbeiter sieht den Fehler und muss bewusst reagieren.
|
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
|
```bash
|
||||||
akzeptiert auch einen gescannten Code), aber kein Kamera-Zugriff verdrahtet.
|
npm install
|
||||||
- Playwright-E2E-Tests und Praktiker-Explorations-Session (Testphase 4) - dafür wird ein
|
npx playwright install chromium
|
||||||
laufendes Test-Deployment gebraucht, kein reiner Code-Schritt.
|
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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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$/);
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zxing/browser": "^0.1.5",
|
"@zxing/browser": "^0.1.5",
|
||||||
@@ -16,6 +18,7 @@
|
|||||||
"react-router-dom": "^6.26.2"
|
"react-router-dom": "^6.26.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.47.2",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
|||||||
@@ -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"] } }],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user