/** * 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, ',', '|'). * Gleichheit akzeptiert '=' (kanonisch) UND '==' (alt) — POLISH-1 stellte den * Client auf Gridifys echtes '=' um; der Mock bleibt für beide robust. */ function matchesFilter(row: Row, filter: string | null): boolean { if (!filter) return true return filter.split(',').every((andPart) => andPart.split('|').some((cond) => { // Gridify "contains": `field=*value` — value runs to the end (optionally /i). // Mirror the REAL backend: no trailing `*` (the old `=\*(.*)\*` regex hid the // SQL-LIKE `name=*a*` bug that returned 0 rows against real Gridify). 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'), } const handler = async (route: Route) => { const request = route.request() const url = new URL(request.url()) const method = request.method() // OPS-1: in Produktion ist die API-Basis der relative Pfad /api (nginx-Proxy); // den Präfix normalisieren, damit der Mock unter beiden Basen funktioniert. const path = url.pathname.replace(/^\/api(?=\/)/, '') // EXPORT-1: Zip-Download (Inhalt egal — der Smoke prüft nur, dass der // Download startet; ein leeres Zip = End-of-central-directory-Record). if (path === '/export' && method === 'GET') { return route.fulfill({ status: 200, contentType: 'application/zip', headers: { 'Content-Disposition': 'attachment; filename="rennmaus-export-e2e.zip"' }, body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]), }) } // FEAT-13: Zuchtprofil (Einstellungen-Seite lädt es vor dem Rendern) if (path === '/settings/breeder-profile') { if (method === 'GET') { return json(route, 200, { zuchtName: 'Zucht der kleinen Chaoten', name: 'Frau Erika Muster', address: 'Musterweg 1, 12345 Musterstadt', phone: '', email: '', homepage: '', city: 'Musterstadt', }) } if (method === 'PUT') return json(route, 204) } // ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ── if (path === '/requests/sync' && method === 'POST') { return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' }) } let rm = path.match(/^\/requests\/([^/]+)\/draft$/) if (rm && method === 'POST') { if (!db.aiConfigured) { return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' }) } const r = db.requests.find((x) => x.id === rm![1]) if (!r) return json(route, 404, { title: 'Not Found' }) r.draftReply = `Hallo ${r.fromName ?? ''},\n\nvielen Dank für deine Anfrage!\n\nViele Grüße` return json(route, 200, r) } rm = path.match(/^\/requests\/([^/]+)\/send$/) if (rm && method === 'POST') { if (!db.mailConfigured) { return json(route, 503, { title: 'MailNotConfigured', detail: 'Gmail ist noch nicht konfiguriert.' }) } const r = db.requests.find((x) => x.id === rm![1]) if (!r) return json(route, 404, { title: 'Not Found' }) r.status = 'Answered' r.answeredAt = '2026-06-06T10:00:00Z' return json(route, 200, r) } rm = path.match(/^\/requests(?:\/([^/]+))?$/) if (rm) { const reqId = rm[1] ? decodeURIComponent(rm[1]) : null if (!reqId && method === 'GET') { return json(route, 200, pagedResponse(db.requests as unknown as Row[], url)) } const r = db.requests.find((x) => x.id === reqId) if (!r) return json(route, 404, { title: 'Not Found' }) if (method === 'GET') return json(route, 200, r) if (method === 'PUT') { // Triage-Vertrag des Backends: assignedContactId wird IMMER übernommen. const body = request.postDataJSON() as { status?: string | null; assignedContactId?: string | null } r.assignedContactId = body.assignedContactId ?? null if (body.status) { r.status = body.status as typeof r.status if (body.status === 'Answered') r.answeredAt ??= '2026-06-06T10:00:00Z' } return json(route, 204) } return json(route, 405) } // SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der // generischen /gerbils/:id-Route abfangen. if (path === '/gerbils/breeders' && method === 'GET') { const values = [ ...new Set( db.gerbils .map((g) => g.originBreeder) .filter((v): v is string => typeof v === 'string' && v.length > 0), ), ].sort() return json(route, 200, values) } // 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 }] : [], }) } // WEB-0b: CMS-Endpunkte (Seiten + Blöcke). Vor den generischen Kollektionen, // weil 'pages'/'blocks' verschachtelte Routen haben und kein Standard-CRUD sind. const toSummary = (p: (typeof db.pages)[number]) => ({ id: p.id, slug: p.slug, title: p.title, seoDescription: p.seoDescription, status: p.status, }) const toBlock = (b: { id: string; order: number; type: string; data: unknown }) => ({ id: b.id, order: b.order, type: b.type, data: b.data, }) const toPage = (p: (typeof db.pages)[number]) => ({ ...toSummary(p), blocks: [...p.blocks].sort((a, b) => a.order - b.order).map(toBlock), }) if (path === '/pages' && method === 'GET') { return json(route, 200, [...db.pages].sort((a, b) => a.slug.localeCompare(b.slug)).map(toSummary)) } // GET /pages/{slug} · PUT /pages/{id} m = path.match(/^\/pages\/([^/]+)$/) if (m && method === 'GET') { const p = db.pages.find((x) => x.slug === decodeURIComponent(m![1])) return p ? json(route, 200, toPage(p)) : json(route, 404, { title: 'Not Found' }) } // PUT /pages/{id} if (m && method === 'PUT') { const p = db.pages.find((x) => x.id === m![1]) if (!p) return json(route, 404, { title: 'Not Found' }) const body = request.postDataJSON() as Partial if (typeof body.slug === 'string') p.slug = body.slug if (typeof body.title === 'string') p.title = body.title p.seoDescription = (body.seoDescription as string | null) ?? null if (body.status === 'Draft' || body.status === 'Published') p.status = body.status return json(route, 204) } // POST /pages/{pageId}/blocks m = path.match(/^\/pages\/([^/]+)\/blocks$/) if (m && method === 'POST') { const p = db.pages.find((x) => x.id === m![1]) if (!p) return json(route, 404, { title: 'Not Found' }) const body = request.postDataJSON() as { type: string; data?: Record; order?: number } const order = body.order ?? (p.blocks.reduce((mx, b) => Math.max(mx, b.order), -1) + 1) const block = { id: newId('blk'), order, type: body.type, data: body.data ?? {} } p.blocks.push(block) return json(route, 201, toBlock(block)) } // PUT /pages/{pageId}/blocks/order (Body: { blockIds }) m = path.match(/^\/pages\/([^/]+)\/blocks\/order$/) if (m && method === 'PUT') { const p = db.pages.find((x) => x.id === m![1]) if (!p) return json(route, 404, { title: 'Not Found' }) const { blockIds } = request.postDataJSON() as { blockIds: string[] } const byId = new Map(p.blocks.map((b) => [b.id, b])) if (blockIds.length !== p.blocks.length || blockIds.some((id) => !byId.has(id))) return json(route, 400, 'blockIds must list exactly the page block ids.') blockIds.forEach((id, i) => { byId.get(id)!.order = i }) return json(route, 204) } // PUT/DELETE /blocks/{id} m = path.match(/^\/blocks\/([^/]+)$/) if (m) { const pageOf = db.pages.find((p) => p.blocks.some((b) => b.id === m![1])) const block = pageOf?.blocks.find((b) => b.id === m![1]) if (!pageOf || !block) return json(route, 404, { title: 'Not Found' }) if (method === 'PUT') { const body = request.postDataJSON() as { type?: string; data?: Record; order?: number } if (typeof body.type === 'string') block.type = body.type if (body.data) block.data = body.data if (typeof body.order === 'number') block.order = body.order return json(route, 204) } if (method === 'DELETE') { pageOf.blocks = pageOf.blocks.filter((b) => b.id !== m![1]) return json(route, 204) } } // 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) } // Beide API-Basen abfangen: absolute Dev-URL und /api-relativ (OPS-1-Proxy). // URL-Prädikat statt Glob: '**/api/**' würde auch Vites Modul-Requests // (/src/api/client.ts …) treffen und die App selbst kaputt-intercepten. await page.route( (url) => url.origin === API_ORIGIN || url.pathname.startsWith('/api/'), handler, ) return db }