Playwright-E2E-Tests für die PWA (Login, Sperrlogik, Fortschritt, Offline-Queue)
CI / backend-tests (push) Successful in 1m6s
CI / frontend-build (push) Successful in 18s

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:
2026-09-04 18:08:08 +02:00
co-authored by Claude Sonnet 5
parent fb676a4a0b
commit e0cbf4b8de
11 changed files with 353 additions and 6 deletions
+41
View File
@@ -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();
});
+30
View File
@@ -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$/);
});
+98
View File
@@ -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();
}
+65
View File
@@ -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);
});
+34
View File
@@ -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);
});