Files
GerbilManager/gerbil-manager-web/e2e/mock-api.ts
Gulum b83e96f552 feat: Rennmausakte, Zucht-Suffix, Gehege-Bilder, Toasts, Infinite-Scroll
Detailseite (Rennmausakte):
- Neugestaltung: Hero-Foto + Overlay, Schnellfakten-Pills, Karten-Sektionen,
  Charakter als Chips, getabte Fotos/Gesundheit/Gewicht.
- Eltern (Vater/Mutter) als Links; Charakter-Karte klappt bei Status
  Abgabe/Verstorben/Abgegeben ein (nur Zucht/Liebhaber offen).
- Gehege wird bei abgegebenen/verstorbenen Tieren ausgeblendet (Detail + Formular).

Listen:
- Infinite Scroll auf allen Listen (Rennmäuse, Würfe, Gehege, Verträge,
  Anfragen, Kontakte) via useInfiniteList/useInfiniteSentinel; stabile
  Sortierung mit id-Tiebreaker (keine doppelten Keys), Back-to-top-Button.
- Kontakte: Rolle-Filter (Züchter/Abnehmer) als Quick-Chips + Sticky-Header.

Zucht-Nachname:
- Namens-Anhängsel der Zucht in den Einstellungen + je Züchter-Kontakt
  (Backend-Spalten + Migration); eigene Tiere zeigen „Name + Suffix".
- „Eigene Zucht" ist die Standard-Herkunft neuer Tiere.

Weiteres:
- Gehege-Bilder: Upload/Galerie auf der Gehege-Detailseite (Backend
  EnclosurePhoto + Endpoints + Migration, geteilte Dateiablage).
- Toast-Rückmeldungen für alle Speichern-Aktionen.
- Checkboxen durch mobile-freundliche Toggle-Schalter ersetzt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:35:30 +02:00

445 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown>
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 "<field> [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<MockDb> {
const db = seedDb()
const collections: Record<string, ReturnType<typeof collection>> = {
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: `<!doctype html><html lang="de"><head><meta charset="utf-8"><title>${p.title}</title></head><body><h1>${p.title}</h1><p>Vorschau-Mock</p></body></html>`,
})
}
// ── 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)
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<typeof p>
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<string, unknown>; 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<string, unknown>; 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`,
}
;(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)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
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
}