216 lines
8.0 KiB
TypeScript
216 lines
8.0 KiB
TypeScript
/**
|
|
* 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
|
|
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<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'),
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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 }]
|
|
: [],
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|