/** * 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 // Mirror Gridify's comma-separated multi-key ordering (e.g. "name,id"): each key // is " [desc]"; later keys act as tiebreakers. const keys = orderBy.split(',').map((part) => { const [field, dir] = part.trim().split(/\s+/) return { field, sign: dir?.toLowerCase() === 'desc' ? -1 : 1 } }) return [...rows].sort((a, b) => { for (const { field, sign } of keys) { const av = String(a[field] ?? '') const bv = String(b[field] ?? '') if (av < bv) return -sign if (av > bv) return sign } return 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'), contracts: collection(db.contracts as unknown as Row[], 'contract'), } 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', nameSuffix: '', }) } if (method === 'PUT') return json(route, 204) } // ── WEB-3: lokale Vorschau (Renderer-Dateien; nur veröffentlichte Seiten) ── if (path === '/render-site' && method === 'GET') { const files = db.pages .filter((p) => p.status === 'Published') .map((p) => ({ path: p.slug === 'start' ? 'index.html' : `${p.slug}/index.html`, size: 1000 })) return json(route, 200, [{ path: 'assets/site.css', size: 500 }, ...files]) } const pv = path.match(/^\/preview(?:\/(.*))?$/) if (pv && method === 'GET') { const key = pv[1] ? pv[1].replace(/\/$/, '') : 'index.html' if (key === 'assets/site.css') { return route.fulfill({ status: 200, contentType: 'text/css', body: 'body{font-family:sans-serif}' }) } const slug = key === 'index.html' ? 'start' : key.replace(/\/index\.html$/, '') const p = db.pages.find((x) => x.slug === slug && x.status === 'Published') if (!p) return json(route, 404, { title: 'Not Found' }) return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: `${p.title}

${p.title}

Vorschau-Mock

`, }) } // ── 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) } // ABGABE: KI-Inserat-Generator (POST /gerbils/sale-ad) — vor der generischen Route if (path === '/gerbils/sale-ad' && method === 'POST') { if (!db.saleAdConfigured) { return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' }) } return json(route, 200, { text: 'Status: FREI\n\n„Zwei Freunde suchen ein Zuhause" – Generierter Inserat-Text für den Mock.' }) } // ABGABE: Vertrag-Download — /contracts/{id}/file (3 Segmente, nicht vom Generic-Handler bedient) const cm = path.match(/^\/contracts\/([^/]+)\/file$/) if (cm && method === 'GET') { return route.fulfill({ status: 200, contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', headers: { 'Content-Disposition': `attachment; filename="vertrag-${cm[1]}.docx"` }, body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]), }) } // 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) // Gehege-Bilder (analog zu Tier-Fotos). m = path.match(/^\/enclosures\/([^/]+)\/photos$/) if (m) { if (method === 'GET') return json(route, 200, []) return json(route, 405) } if (path.match(/^\/enclosure-photos\/[^/]+$/) && method === 'DELETE') return json(route, 204) // Gehege-Reinigungszyklus: nächste fällige Reinigung = letzte Reinigung + Zyklus (Tage). const nextCleaning = (lastCleaned?: string | null, cycleDays?: number | null): string | null => { if (!lastCleaned || cycleDays == null || cycleDays <= 0) return null const d = new Date(`${lastCleaned}T00:00:00Z`) d.setUTCDate(d.getUTCDate() + cycleDays) return d.toISOString().slice(0, 10) } // "Als gereinigt markieren": setzt letzte Reinigung auf heute (wie das echte Backend). m = path.match(/^\/enclosures\/([^/]+)\/mark-cleaned$/) if (m && method === 'POST') { const enc = db.enclosures.find((x) => x.id === m![1]) if (!enc) return json(route, 404, { title: 'Not Found' }) enc.lastCleanedDate = new Date().toISOString().slice(0, 10) enc.nextCleaningDate = nextCleaning(enc.lastCleanedDate, enc.cleaningCycleDays) return json(route, 200, enc) } 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) } } // ABGABE: POST /contracts — baut url-Feld und markiert Tiere als Abgegeben if (path === '/contracts' && method === 'POST') { const body = request.postDataJSON() as { contactId: string; gerbilIds: string[]; price: number; handoverDate: string; contractDate?: string | null } const id = newId('contract') const contract = { id, contactId: body.contactId, gerbilIds: body.gerbilIds ?? [], price: body.price, handoverDate: body.handoverDate, contractDate: body.contractDate ?? body.handoverDate, fileName: `vertrag-${id}.docx`, createdAt: new Date().toISOString(), url: `/contracts/${id}/file`, hasFile: true, } ;(db.contracts as unknown as Row[]).push(contract as unknown as Row) // Tiere auf GivenAway setzen (wie das echte Backend) for (const gid of body.gerbilIds ?? []) { const g = db.gerbils.find((x) => x.id === gid) if (g) { g.status = 'GivenAway' g.receiverContactId = body.contactId g.goHomeDate = body.handoverDate } } return json(route, 201, contract) } // FEAT-NAMEGEN: /names/suggest if (path === '/names/suggest' && method === 'GET') { if (!db.namesConfigured) { return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' }) } const letter = url.searchParams.get('letter')?.toUpperCase() const allSuggestions = [ { name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' }, { name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' }, { name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' }, { name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' }, { name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' }, { name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' }, ] const result = letter ? allSuggestions.filter((s) => s.name.startsWith(letter)) : allSuggestions return json(route, 200, result) } // GEPRÜFTE TIERE: „vollständig korrekt"-Markierung / Schutz (/verified-gerbils). if (path === '/verified-gerbils' && method === 'GET') { return json(route, 200, [...db.verified].reverse()) } const verMatch = path.match(/^\/verified-gerbils\/([^/]+)$/) if (verMatch) { const gid = decodeURIComponent(verMatch[1]) const vIdx = db.verified.findIndex((v) => v.gerbilId === gid) if (method === 'GET') { return vIdx >= 0 ? json(route, 200, db.verified[vIdx]) : json(route, 404, { title: 'Not Found' }) } if (method === 'POST') { const g = db.gerbils.find((x) => x.id === gid) if (!g) return json(route, 404, { title: 'Not Found' }) const body = (request.postDataJSON() ?? {}) as { note?: string | null } const row = { gerbilId: gid, entityName: g.name ?? null, isVerified: true, status: 'unchanged', verifiedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), note: body.note ?? null, protectedFields: ['name', 'gender', 'dateOfBirth', 'genotype'], importDiff: [], } if (vIdx >= 0) db.verified[vIdx] = row else db.verified.push(row) return json(route, 200, row) } if (method === 'DELETE') { if (vIdx < 0) return json(route, 404, { title: 'Not Found' }) db.verified.splice(vIdx, 1) return json(route, 204) } return json(route, 405) } // FEEDBACK: "Fehler melden" — POST persistiert, GET listet (neueste zuerst). if (path === '/feedback') { if (method === 'POST') { const body = request.postDataJSON() as Row const created = { id: newId('feedback'), ...body, userAgent: request.headers()['user-agent'] ?? null, createdAt: new Date().toISOString(), status: 'Open', resolvedAt: null, question: null, answer: null, answeredAt: null, fixNote: null, agentContext: null, thread: [], reopenedAt: null, deletedAt: null, category: null, helpful: null, attachments: [], } db.feedback.push(created) return json(route, 201, created) } if (method === 'GET') { // attachments immer als Array liefern (Mock-Daten haben das Feld evtl. nicht). const rows = [...db.feedback].reverse().map((f) => ({ ...f, attachments: f.attachments ?? [] })) return json(route, 200, rows) } return json(route, 405) } // WEB-PUSH: im Mock deaktiviert (keine VAPID-Schlüssel) → PushToggle blendet sich aus. if (path === '/push/vapid-public-key' && method === 'GET') { return json(route, 200, { enabled: false, publicKey: null }) } if ((path === '/push/subscribe' || path === '/push/unsubscribe') && method === 'POST') { return json(route, 200, {}) } // FEEDBACK-ANHÄNGE: hochladen (POST /feedback/{id}/attachments). const attUpload = path.match(/^\/feedback\/([^/]+)\/attachments$/) if (attUpload && method === 'POST') { const fid = decodeURIComponent(attUpload[1]) const row = db.feedback.find((f) => f.id === fid) if (!row) return json(route, 404, { title: 'Not Found' }) const body = request.postDataJSON() as { fileName?: string; contentType?: string; dataBase64?: string } const meta = { id: newId('att'), fileName: body.fileName ?? 'datei', contentType: body.contentType ?? 'application/octet-stream', size: (body.dataBase64 ?? '').length, } const list = (row.attachments as unknown[] | undefined) ?? [] list.push(meta) row.attachments = list return json(route, 201, meta) } // FEEDBACK-ANHÄNGE: löschen (DELETE /feedback/attachments/{attId}). const attDelete = path.match(/^\/feedback\/attachments\/([^/]+)$/) if (attDelete && method === 'DELETE') { const attId = decodeURIComponent(attDelete[1]) for (const f of db.feedback) { const list = (f.attachments as { id: string }[] | undefined) ?? [] const i = list.findIndex((a) => a.id === attId) if (i >= 0) { list.splice(i, 1) f.attachments = list return json(route, 204) } } return json(route, 404, { title: 'Not Found' }) } // FEEDBACK-TICKETS: Wiederherstellen aus dem Papierkorb (Soft-Delete aufheben). const restoreMatch = path.match(/^\/feedback\/([^/]+)\/restore$/) if (restoreMatch) { const fid = decodeURIComponent(restoreMatch[1]) const row = db.feedback.find((f) => f.id === fid) if (!row) return json(route, 404, { title: 'Not Found' }) if (method === 'POST') { row.deletedAt = null return json(route, 200, row) } return json(route, 405) } // FEEDBACK-TICKETS: "Meine Tickets" — PUT (Nachricht/Status) und DELETE. const fm = path.match(/^\/feedback\/([^/]+)$/) if (fm) { const fid = decodeURIComponent(fm[1]) const idx = db.feedback.findIndex((f) => f.id === fid) if (idx < 0) return json(route, 404, { title: 'Not Found' }) if (method === 'PUT') { const body = request.postDataJSON() as { message?: string status?: string question?: string answer?: string fixNote?: string agentContext?: string category?: string helpful?: boolean } const row = db.feedback[idx] // Zustand vor den Mutationen (für die Wiederöffnen-Erkennung). const wasResolved = row.status === 'Resolved' const hadOpenRueckfrage = typeof row.question === 'string' && !!(row.question as string).trim() if (typeof body.message === 'string') { if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.') row.message = body.message.trim() } // Rückfrage anhängen → NeedsInfo (außer bereits Resolved). Eine vorhandene // Frage/Antwort-Runde wird zuerst in den Verlauf (thread) verschoben. if (typeof body.question === 'string') { const q = body.question.trim() if (q.length > 0) { const thread = (row.thread as Record[] | undefined) ?? [] if (typeof row.question === 'string' && row.question) thread.push({ role: 'maintainer', text: row.question, at: row.createdAt ?? null }) if (typeof row.answer === 'string' && row.answer) thread.push({ role: 'breeder', text: row.answer, at: row.answeredAt ?? null }) row.thread = thread row.answer = null row.answeredAt = null row.question = q if (row.status !== 'Resolved') { row.status = 'NeedsInfo' row.resolvedAt = null } } else { row.question = null } } // Antwort der Züchterin → Answered + answeredAt. if (typeof body.answer === 'string') { const a = body.answer.trim() if (a.length === 0) { row.answer = null row.answeredAt = null } else { row.answer = a row.answeredAt = new Date().toISOString() row.status = 'Answered' row.resolvedAt = null } } if (typeof body.status === 'string') { const s = body.status const resolved = s.toLowerCase() === 'resolved' row.status = resolved ? 'Resolved' : s === 'NeedsInfo' || s === 'Answered' ? s : 'Open' row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null // „Wieder geöffnet am" nur, wenn ein echt gelöstes Ticket OHNE offene Rückfrage // wieder geöffnet wird (s. Backend). if (!resolved && wasResolved && !hadOpenRueckfrage) row.reopenedAt = new Date().toISOString() } // Changelog (fixNote) — laienverständlich, sichtbar. if (typeof body.fixNote === 'string') { const note = body.fixNote.trim() row.fixNote = note.length === 0 ? null : note } // Internes Agenten-Arbeitsgedächtnis — ändert den Status nicht. if (typeof body.agentContext === 'string') { const c = body.agentContext.trim() row.agentContext = c.length === 0 ? null : c } if (typeof body.category === 'string') { const cat = body.category.trim() row.category = cat.length === 0 ? null : cat } if (typeof body.helpful === 'boolean') row.helpful = body.helpful return json(route, 200, row) } if (method === 'DELETE') { // Soft-Delete: in den Papierkorb verschieben (nicht entfernen). const target = db.feedback[idx] if (!target.deletedAt) target.deletedAt = new Date().toISOString() return json(route, 204) } return json(route, 405) } // RPRO3: RennmausPro-III-Backup-Import (multipart-Upload). Mock liefert eine feste // Auswertung bzw. ein Import-Ergebnis — keine echte Datei-Verarbeitung. if (path === '/import/rpro3/analyze' && method === 'POST') { return json(route, 200, db.rpro3.analyze) } if (path === '/import/rpro3/execute' && method === 'POST') { return json(route, 200, db.rpro3.execute) } // ERWERB: Erwerb/Kauf je Tier — GET ?gerbilId= (Array, kein Gridify-Paging), // POST/PUT/DELETE. Vor den generischen Kollektionen, weil GET kein {items}-Objekt // liefert und nach gerbilId statt Gridify-filter selektiert. const acqMatch = path.match(/^\/acquisitions(?:\/([^/]+))?$/) if (acqMatch) { const acqId = acqMatch[1] ? decodeURIComponent(acqMatch[1]) : null if (!acqId) { if (method === 'GET') { const gid = url.searchParams.get('gerbilId') const rows = db.acquisitions.filter((a) => !gid || a.gerbilId === gid) return json(route, 200, [...rows].reverse()) } if (method === 'POST') { const created = { id: newId('acq'), sourceContactId: null, date: null, price: null, note: null, ...(request.postDataJSON() as Row), createdAt: new Date().toISOString(), } db.acquisitions.push(created) return json(route, 201, created) } return json(route, 405) } const ai = db.acquisitions.findIndex((a) => a.id === acqId) if (method === 'GET') { return ai >= 0 ? json(route, 200, db.acquisitions[ai]) : json(route, 404, { title: 'Not Found' }) } if (method === 'PUT') { if (ai < 0) return json(route, 404, { title: 'Not Found' }) Object.assign(db.acquisitions[ai], request.postDataJSON() as Row) return json(route, 204) } if (method === 'DELETE') { if (ai < 0) return json(route, 404, { title: 'Not Found' }) db.acquisitions.splice(ai, 1) return json(route, 204) } return json(route, 405) } // ABGABE-STATUS: /reservations — Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben). if (path === '/reservations') { const allowed = ['verfuegbar', 'reserviert', 'abgegeben'] const normStatus = (s: unknown) => typeof s === 'string' && allowed.includes(s.trim().toLowerCase()) ? s.trim().toLowerCase() : 'verfuegbar' if (method === 'POST') { const body = request.postDataJSON() as Row if (!body.gerbilId) return json(route, 400, 'GerbilId ist erforderlich.') const nowIso = new Date().toISOString() const created = { id: newId('reservation'), gerbilId: body.gerbilId, gerbilName: body.gerbilName ?? null, status: normStatus(body.status), reservedForContactId: body.reservedForContactId ?? null, contactName: body.contactName ?? null, appointmentDate: body.appointmentDate ?? null, price: body.price ?? null, note: body.note ?? null, handedOverDate: body.handedOverDate ?? null, createdAt: nowIso, updatedAt: nowIso, } db.reservations.push(created) return json(route, 201, created) } if (method === 'GET') { const gid = url.searchParams.get('gerbilId') const rows = gid ? db.reservations.filter((r) => r.gerbilId === gid) : [...db.reservations] return json(route, 200, rows.slice().reverse()) } return json(route, 405) } const resm = path.match(/^\/reservations\/([^/]+)$/) if (resm) { const allowed = ['verfuegbar', 'reserviert', 'abgegeben'] const normStatus = (s: unknown) => typeof s === 'string' && allowed.includes(s.trim().toLowerCase()) ? s.trim().toLowerCase() : 'verfuegbar' const rid = decodeURIComponent(resm[1]) const idx = db.reservations.findIndex((r) => r.id === rid) if (idx < 0) return json(route, 404, { title: 'Not Found' }) if (method === 'PUT') { const body = request.postDataJSON() as Row const row = db.reservations[idx] if (typeof body.status === 'string') row.status = normStatus(body.status) if ('gerbilName' in body) row.gerbilName = body.gerbilName ?? null if ('contactName' in body) row.contactName = body.contactName ?? null if ('note' in body) row.note = body.note ?? null if ('reservedForContactId' in body) row.reservedForContactId = body.reservedForContactId ?? null if ('appointmentDate' in body) row.appointmentDate = body.appointmentDate ?? null if ('price' in body) row.price = body.price ?? null if ('handedOverDate' in body) row.handedOverDate = body.handedOverDate ?? null row.updatedAt = new Date().toISOString() return json(route, 200, row) } if (method === 'DELETE') { db.reservations.splice(idx, 1) return json(route, 204) } return json(route, 405) } // WAITLIST: Warteliste/Nachfrage — GET liefert ein BLANKES Array (kein Gridify-Envelope), // POST/PUT/DELETE wie eine einfache Kollektion. Vor den generischen Kollektionen, weil // die GET-Antwort kein paginiertes Envelope ist. const wlm = path.match(/^\/waiting-list(?:\/([^/]+))?$/) if (wlm) { const wlId = wlm[1] ? decodeURIComponent(wlm[1]) : null const ALLOWED = ['offen', 'erfuellt', 'storniert'] const normStatus = (s: unknown): string | null => { if (s === undefined || s === null || String(s).trim() === '') return 'offen' const v = String(s).trim() return ALLOWED.includes(v) ? v : null } if (!wlId) { if (method === 'GET') { // Neueste Anfrage zuerst (requestedAt desc, dann createdAt desc). const sorted = [...db.waitingList].sort((a, b) => { const ar = String(a.requestedAt ?? '') const br = String(b.requestedAt ?? '') if (ar !== br) return ar < br ? 1 : -1 return String(a.createdAt ?? '') < String(b.createdAt ?? '') ? 1 : -1 }) return json(route, 200, sorted) } if (method === 'POST') { const body = request.postDataJSON() as Record const status = normStatus(body.status) if (status === null) return json(route, 400, 'Ungültiger Status.') if (!body.contactId && (!body.contactName || String(body.contactName).trim() === '')) return json(route, 400, 'Kontakt oder Name ist erforderlich.') const created = { id: newId('wl'), contactId: body.contactId ?? null, contactName: body.contactName ?? null, wishColor: body.wishColor ?? null, wishGender: body.wishGender ?? null, requestedAt: body.requestedAt ?? null, status, note: body.note ?? null, createdAt: new Date().toISOString(), } db.waitingList.push(created) return json(route, 201, created) } return json(route, 405) } const wlIdx = db.waitingList.findIndex((r) => r.id === wlId) if (method === 'GET') { return wlIdx >= 0 ? json(route, 200, db.waitingList[wlIdx]) : json(route, 404, { title: 'Not Found' }) } if (method === 'PUT') { if (wlIdx < 0) return json(route, 404, { title: 'Not Found' }) const body = request.postDataJSON() as Record const status = normStatus(body.status) if (status === null) return json(route, 400, 'Ungültiger Status.') if (!body.contactId && (!body.contactName || String(body.contactName).trim() === '')) return json(route, 400, 'Kontakt oder Name ist erforderlich.') Object.assign(db.waitingList[wlIdx], { ...body, status }) return json(route, 200, db.waitingList[wlIdx]) } if (method === 'DELETE') { if (wlIdx < 0) return json(route, 404, { title: 'Not Found' }) db.waitingList.splice(wlIdx, 1) return json(route, 204) } return json(route, 405) } // RÜCKNAHMEN: zurückgenommene Tiere (CRUD /returns, GET optional ?gerbilId=). // Eigene Route (kein Gridify-Paging — die API liefert ein flaches Array). const ret = path.match(/^\/returns(?:\/([^/]+))?$/) if (ret) { const retId = ret[1] ? decodeURIComponent(ret[1]) : null if (!retId) { if (method === 'GET') { const gid = url.searchParams.get('gerbilId') const rows = gid ? db.returns.filter((r) => r.gerbilId === gid) : db.returns return json(route, 200, [...rows].reverse()) } if (method === 'POST') { const body = request.postDataJSON() as Row if (!body.gerbilId && !body.gerbilName) { return json(route, 400, 'Es muss ein Tier angegeben werden.') } const created = { id: newId('return'), ...body, createdAt: new Date().toISOString() } db.returns.push(created) return json(route, 201, created) } return json(route, 405) } const idx = db.returns.findIndex((r) => r.id === retId) if (method === 'PUT') { if (idx < 0) return json(route, 404, { title: 'Not Found' }) Object.assign(db.returns[idx], request.postDataJSON() as Row) return json(route, 200, db.returns[idx]) } if (method === 'DELETE') { if (idx < 0) return json(route, 404, { title: 'Not Found' }) db.returns.splice(idx, 1) return json(route, 204) } return json(route, 405) } // EXHIBITION: Ausstellungs-/Show-Ergebnisse je Tier (POST/GET ?gerbilId=, PUT, DELETE). // Plain-Liste (kein Gridify), gefiltert per ?gerbilId, neueste zuerst. if (path === '/exhibitions') { if (method === 'GET') { const gerbilId = url.searchParams.get('gerbilId') const rows = (db.exhibitions as Row[]).filter( (r) => !gerbilId || r.gerbilId === gerbilId, ) const sorted = [...rows].sort((a, b) => String(b.date ?? '').localeCompare(String(a.date ?? '')), ) return json(route, 200, sorted) } if (method === 'POST') { const body = request.postDataJSON() as Row if (!String(body.eventName ?? '').trim()) return json(route, 400, 'EventName darf nicht leer sein.') const created = { id: newId('exhibition'), gerbilId: body.gerbilId ?? null, entityName: body.entityName ?? null, eventName: String(body.eventName).trim(), date: body.date ?? null, placement: body.placement ?? null, award: body.award ?? null, note: body.note ?? null, createdAt: new Date().toISOString(), } ;(db.exhibitions as Row[]).push(created) return json(route, 201, created) } return json(route, 405) } const em = path.match(/^\/exhibitions\/([^/]+)$/) if (em) { const eid = decodeURIComponent(em[1]) const idx = (db.exhibitions as Row[]).findIndex((r) => r.id === eid) if (idx < 0) return json(route, 404, { title: 'Not Found' }) if (method === 'GET') return json(route, 200, db.exhibitions[idx]) if (method === 'PUT') { const body = request.postDataJSON() as Row if ('eventName' in body && !String(body.eventName ?? '').trim()) return json(route, 400, 'EventName darf nicht leer sein.') Object.assign(db.exhibitions[idx], body) return json(route, 200, db.exhibitions[idx]) } if (method === 'DELETE') { ;(db.exhibitions as Row[]).splice(idx, 1) return json(route, 204) } return json(route, 405) } // REFS: getippte Kurz-Verweise (Shortlinks) auflösen. Spiegelt das Backend: // Präfix-Match (>=6 Hex, bindestrich-tolerant) auf der bindestrich-losen Id; // mehrdeutig/unbekannt => id/name null. if (path === '/refs/resolve' && method === 'POST') { const body = request.postDataJSON() as { refs?: { type?: string; code?: string }[] } const tableFor: Record = { tier: db.gerbils as unknown as Row[], kontakt: db.contacts as unknown as Row[], wurf: db.litters as unknown as Row[], gehege: db.enclosures as unknown as Row[], } const normCode = (code: string) => (code ?? '').toLowerCase().replace(/[^0-9a-f]/g, '') const results = (body.refs ?? []).map((r) => { const type = (r.type ?? '').toLowerCase() const prefix = normCode(r.code ?? '') const rows = tableFor[type] if (!rows || prefix.length < 6) return { type: r.type, code: r.code, id: null, name: null } const matches = rows.filter((row) => String(row.id ?? '').toLowerCase().replace(/-/g, '').startsWith(prefix), ) if (matches.length === 1) { return { type: r.type, code: r.code, id: String(matches[0].id), name: String(matches[0].name ?? '') } } return { type: r.type, code: r.code, id: null, name: null } }) return json(route, 200, results) } // 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 } // Gehege: berechnetes Feld nextCleaningDate wie das echte Backend ableiten. if (m[1] === 'enclosures') { created.nextCleaningDate = nextCleaning( created.lastCleanedDate as string | null, created.cleaningCycleDays as number | null, ) } 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) if (m[1] === 'enclosures') { col.rows[idx].nextCleaningDate = nextCleaning( col.rows[idx].lastCleanedDate as string | null, col.rows[idx].cleaningCycleDays as number | null, ) } 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 }