From ec5e755e727c562ccbc1ebd1c53db0fdeef6a2c2 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:16:52 +0200 Subject: [PATCH] QA-1: stateful in-memory mock API per route interception (DATA-2 contract: Gridify paging, camelCase DTOs, 409 delete-conflicts) + fixtures --- gerbil-manager-web/e2e/fixtures.ts | 68 ++++++++++++ gerbil-manager-web/e2e/mock-api.ts | 163 ++++++++++++++++++++++++++++ gerbil-manager-web/e2e/mock-data.ts | 141 ++++++++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 gerbil-manager-web/e2e/fixtures.ts create mode 100644 gerbil-manager-web/e2e/mock-api.ts create mode 100644 gerbil-manager-web/e2e/mock-data.ts diff --git a/gerbil-manager-web/e2e/fixtures.ts b/gerbil-manager-web/e2e/fixtures.ts new file mode 100644 index 0000000..358f507 --- /dev/null +++ b/gerbil-manager-web/e2e/fixtures.ts @@ -0,0 +1,68 @@ +/** + * QA-1: Test-Fixtures. + * + * - MOCK-Modus (Standard): installiert vor jedem Test den In-Memory-Mock + * (frischer Datenbestand pro Test). + * - LIVE-Modus (E2E_BASE_URL gesetzt): kein Mock; fixture-gebundene Tests + * überspringen sich über `skipUnlessMock()`. + * + * Deutsche Oberflächen-Texte werden DIREKT aus src/strings/de.ts importiert — + * die Suite bleibt damit automatisch synchron zur App. + */ +import { test as base, expect, type Page } from '@playwright/test' +import { de } from '../src/strings/de' +import { installMockApi } from './mock-api' +import type { MockDb } from './mock-data' + +export const LIVE = Boolean(process.env.E2E_BASE_URL) + +export { expect } +export { de } + +interface Fixtures { + /** Im Mock-Modus der pro Test frische Datenbestand, sonst null. */ + mockDb: MockDb | null +} + +export const test = base.extend({ + mockDb: [ + async ({ page }, use) => { + const db = LIVE ? null : await installMockApi(page) + await use(db) + }, + { auto: true }, + ], +}) + +/** Test überspringen, wenn er Seed-Daten des Mocks voraussetzt. */ +export function skipUnlessMock() { + test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)') +} + +/** + * Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken, + * Kontakte und Statistik hinter dem „Mehr“-Blatt. + */ +export async function gotoSection(page: Page, label: string) { + const nav = page.getByRole('navigation') + // App noch nicht geladen (about:blank) -> erst zur Startseite + if (!(await nav.isVisible())) { + await page.goto('/') + await expect(nav).toBeVisible() + } + const link = nav.getByRole('link', { name: label }) + if (!(await link.isVisible())) { + await nav.getByRole('button', { name: de.nav.more }).click() + await expect(link).toBeVisible() + } + await link.click() +} + +/** window.confirm des nächsten Dialogs annehmen. */ +export function acceptNextDialog(page: Page) { + page.once('dialog', (d) => void d.accept()) +} + +/** Eindeutiger Name für LIVE-taugliche Create-Flows. */ +export const uniqueName = (prefix: string) => + `${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts new file mode 100644 index 0000000..240aaa9 --- /dev/null +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -0,0 +1,163 @@ +/** + * QA-1: Stateful In-Memory-Mock der GerbilManagerWebAPI per Route-Interception. + * + * Bedient den DATA-2-Vertrag: Gridify-paged Listen ({items, totalCount, page, + * pageSize}), camelCase-DTOs, 409 Conflict bei referenzierten Becken/Kontakten + * (von Dwight bestätigt). Jeder Test bekommt über die Fixture in fixtures.ts + * einen FRISCHEN Datenbestand — CRUD-Flüsse mutieren ihn wie das echte Backend. + */ +import type { Page, Route } from '@playwright/test' +import { seedDb, type MockDb } from './mock-data' + +/** Default-API-Origin des Frontends (src/api/client.ts). */ +const API_ORIGIN = 'http://localhost:5179' + +type Row = Record + +let seq = 0 +const newId = (prefix: string) => `${prefix}-e2e-${++seq}` + +/** Gridify-Escapes entfernen (\, vor Sonderzeichen). */ +const unescapeGridify = (s: string) => s.replace(/\\(.)/g, '$1') + +/** Mini-Gridify: genau die Ausdrücke, die die App baut (==, contains, ',', '|'). */ +function matchesFilter(row: Row, filter: string | null): boolean { + if (!filter) return true + return filter.split(',').every((andPart) => + andPart.split('|').some((cond) => { + let m = cond.match(/^(\w+)=\*(.*)\*(\/i)?$/) + if (m) { + return String(row[m[1]] ?? '') + .toLowerCase() + .includes(unescapeGridify(m[2]).toLowerCase()) + } + m = cond.match(/^(\w+)==(.*)$/) + if (m) return String(row[m[1]] ?? '') === unescapeGridify(m[2]) + return true + }), + ) +} + +function applyOrderBy(rows: Row[], orderBy: string | null): Row[] { + if (!orderBy) return rows + const [field, dir] = orderBy.split(/\s+/) + const sign = dir?.toLowerCase() === 'desc' ? -1 : 1 + return [...rows].sort((a, b) => { + const av = String(a[field] ?? '') + const bv = String(b[field] ?? '') + return av < bv ? -sign : av > bv ? sign : 0 + }) +} + +function pagedResponse(rows: Row[], url: URL) { + const filtered = rows.filter((r) => matchesFilter(r, url.searchParams.get('filter'))) + const sorted = applyOrderBy(filtered, url.searchParams.get('orderBy')) + const page = Number(url.searchParams.get('page') ?? '1') + const pageSize = Number(url.searchParams.get('pageSize') ?? '20') + return { + items: sorted.slice((page - 1) * pageSize, page * pageSize), + totalCount: filtered.length, + page, + pageSize, + } +} + +const json = (route: Route, status: number, body?: unknown) => + route.fulfill({ + status, + contentType: 'application/json; charset=utf-8', + body: body === undefined ? '' : JSON.stringify(body), + }) + +/** + * Generische CRUD-Kollektion. `deleteConflict` bildet die 409-Regel des + * Backends ab (Becken nicht leer / Kontakt referenziert). + */ +function collection( + rows: Row[], + idPrefix: string, + deleteConflict?: (id: string, db: MockDb) => boolean, +) { + return { rows, idPrefix, deleteConflict } +} + +export async function installMockApi(page: Page): Promise { + const db = seedDb() + + const collections: Record> = { + gerbils: collection(db.gerbils as unknown as Row[], 'gerbil'), + litters: collection(db.litters as unknown as Row[], 'litter'), + enclosures: collection(db.enclosures as unknown as Row[], 'enc', (id, d) => + d.gerbils.some((g) => g.enclosureId === id), + ), + contacts: collection(db.contacts as unknown as Row[], 'con', (id, d) => + d.gerbils.some((g) => g.originContactId === id || g.receiverContactId === id), + ), + 'color-varieties': collection(db.colorVarieties as unknown as Row[], 'cv'), + 'health-records': collection(db.healthRecords as unknown as Row[], 'hr'), + 'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'), + } + + await page.route(`${API_ORIGIN}/**`, async (route) => { + const request = route.request() + const url = new URL(request.url()) + const method = request.method() + const path = url.pathname + + // Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge) + let m = path.match(/^\/gerbils\/([^/]+)\/photos$/) + if (m) { + if (method === 'GET') return json(route, 200, []) + return json(route, 405) + } + if (path.match(/^\/photos\/[^/]+$/) && method === 'DELETE') return json(route, 204) + m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/) + if (m) { + const isKruemel = m[1] === 'kruemel' + return json(route, 200, { + coefficient: isKruemel ? 0.0625 : 0, + percent: isKruemel ? 6.25 : 0, + generationsAvailable: isKruemel ? 5 : 2, + commonAncestors: isKruemel + ? [{ id: 'anton', name: 'Anton', contribution: 0.0625 }] + : [], + }) + } + + // Generische Kollektionen: / und // + m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) + const col = m ? collections[m[1]] : undefined + if (!m || !col) return json(route, 404, { title: 'Not Found' }) + const id = m[2] ? decodeURIComponent(m[2]) : null + + if (!id) { + if (method === 'GET') return json(route, 200, pagedResponse(col.rows, url)) + if (method === 'POST') { + const body = request.postDataJSON() as Row + const created = { id: newId(col.idPrefix), ...body } + col.rows.push(created) + return json(route, 201, created) + } + return json(route, 405) + } + + const idx = col.rows.findIndex((r) => r.id === id) + if (method === 'GET') { + return idx >= 0 ? json(route, 200, col.rows[idx]) : json(route, 404, { title: 'Not Found' }) + } + if (method === 'PUT') { + if (idx < 0) return json(route, 404, { title: 'Not Found' }) + Object.assign(col.rows[idx], request.postDataJSON() as Row) + return json(route, 200, col.rows[idx]) + } + if (method === 'DELETE') { + if (idx < 0) return json(route, 404, { title: 'Not Found' }) + if (col.deleteConflict?.(id, db)) return json(route, 409, { title: 'Conflict' }) + col.rows.splice(idx, 1) + return json(route, 204) + } + return json(route, 405) + }) + + return db +} diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts new file mode 100644 index 0000000..30f8dab --- /dev/null +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -0,0 +1,141 @@ +/** + * QA-1: Seed-Daten für den Mock-Modus — geformt exakt nach dem DATA-2-Vertrag + * (camelCase-DTOs, Gridify-paged Listen). Datensatz angelehnt an Kellys + * FEAT-4-Verifikationsdaten (mehrgenerationiger Stammbaum inkl. gemeinsamem + * Vorfahren Anton), erweitert um Becken/Kontakte/Gesundheit/Gewicht für die + * FEAT-2/FEAT-6-Flüsse. + */ +import type { Contact, Enclosure, Gerbil, Litter } from '../src/api/types' + +export interface HealthRecordRow { + id: string + gerbilId: string + date: string + type: string + description: string + veterinarian: string | null + createdAt: string +} + +export interface WeightRecordRow { + id: string + gerbilId: string + date: string + weightGrams: number + notes: string | null +} + +export interface MockDb { + gerbils: Gerbil[] + litters: Litter[] + enclosures: Enclosure[] + contacts: Contact[] + colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[] + healthRecords: HealthRecordRow[] + weightRecords: WeightRecordRow[] +} + +function gerbil( + id: string, + name: string, + gender: 'male' | 'female', + dateOfBirth: string, + litterId: string | null, + colorVarietyId: string | null, + genotype: string | null = null, +): Gerbil { + return { + id, + name, + gender, + status: 'Active', + dateOfBirth, + dateOfDeath: null, + causeOfDeath: null, + goHomeDate: null, + litterId, + enclosureId: null, + colorVarietyId, + originContactId: null, + receiverContactId: null, + genotype, + notes: null, + } +} + +/** Frischer, unabhängiger Datenbestand pro Test. */ +export function seedDb(): MockDb { + const gerbils: Gerbil[] = [ + { + ...gerbil('kruemel', 'Krümel', 'female', '2025-03-12', 'w-kruemel', 'cv-agouti', 'Aa CC DD EE GG Pp spsp rere'), + enclosureId: 'enc-gross', + originContactId: 'con-meier', + }, + { ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross' }, + gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'), + gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'), + gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'), + gerbil('karlsson', 'Karlsson', 'male', '2022-02-02', 'w-karlsson', 'cv-blau'), + gerbil('smilla', 'Smilla', 'female', '2022-09-30', null, 'cv-himalaya'), + gerbil('anton', 'Anton', 'male', '2019-07-07', 'w-anton', 'cv-agouti'), + gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'), + gerbil('frieda', 'Frieda', 'female', '2020-01-18', null, 'cv-gold'), + gerbil('emil', 'Emil', 'male', '2017-03-03', 'w-emil', 'cv-agouti'), + gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'), + gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), + // Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve + { ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' }, + { ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' }, + { ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' }, + ] + + const litters: Litter[] = [ + { id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna' }, + { id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' }, + { id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' }, + { id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' }, + // Anton ist auch Karlssons Vater -> gemeinsamer Vorfahre (Inzucht-Demo) + { id: 'w-karlsson', name: 'Wurf C', date: '2022-02-02', totalBorn: 5, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'frieda' }, + { id: 'w-anton', name: 'Wurf A', date: '2019-07-07', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'emil', motherId: 'hilde' }, + { id: 'w-emil', name: 'Wurf E', date: '2017-03-03', totalBorn: 2, expectedGoHomeDate: null, notes: null, fatherId: 'max', motherId: null }, + ] + + const enclosures: Enclosure[] = [ + { id: 'enc-gross', name: 'Großbecken', notes: '120×50 cm' }, + { id: 'enc-leer', name: 'Quarantänebecken', notes: null }, + ] + + const contacts: Contact[] = [ + { id: 'con-meier', name: 'Zoohandlung Meier', contactInfo: 'meier@example.de', notes: null }, + { id: 'con-huber', name: 'Familie Huber', contactInfo: '0151 2345678', notes: null }, + { id: 'con-frei', name: 'Züchterin Frei', contactInfo: null, notes: 'unverknüpft' }, + ] + + const colorVarieties = [ + { id: 'cv-agouti', name: 'Agouti', canonicalGenotype: 'AA CC DD EE GG PP spsp rere', sortOrder: 1 }, + { id: 'cv-schwarz', name: 'Schwarz', canonicalGenotype: 'aa CC DD EE GG PP spsp rere', sortOrder: 2 }, + { id: 'cv-gold', name: 'Gold', canonicalGenotype: 'AA CC DD EE GG pp spsp rere', sortOrder: 3 }, + { id: 'cv-blau', name: 'Blau', canonicalGenotype: 'aa CC dd EE GG PP spsp rere', sortOrder: 4 }, + { id: 'cv-himalaya', name: 'Himalaya', canonicalGenotype: 'AA chch DD EE GG PP spsp rere', sortOrder: 5 }, + { id: 'cv-schwarz-schecke', name: 'Schwarz Schecke', canonicalGenotype: 'aa CC DD EE GG PP Spsp rere', sortOrder: 6 }, + ] + + const healthRecords: HealthRecordRow[] = [ + { + id: 'hr-1', + gerbilId: 'kruemel', + date: '2026-01-15', + type: 'Vaccination', + description: 'Jahresimpfung', + veterinarian: 'Dr. Vogel', + createdAt: '2026-01-15T10:00:00Z', + }, + ] + + const weightRecords: WeightRecordRow[] = [ + { id: 'wr-1', gerbilId: 'kruemel', date: '2026-05-01', weightGrams: 78, notes: null }, + { id: 'wr-2', gerbilId: 'kruemel', date: '2026-05-20', weightGrams: 82, notes: null }, + ] + + return { gerbils, litters, enclosures, contacts, colorVarieties, healthRecords, weightRecords } +}