QA-1: stateful in-memory mock API per route interception (DATA-2 contract: Gridify paging, camelCase DTOs, 409 delete-conflicts) + fixtures
This commit is contained in:
68
gerbil-manager-web/e2e/fixtures.ts
Normal file
68
gerbil-manager-web/e2e/fixtures.ts
Normal file
@@ -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<Fixtures>({
|
||||
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)}`
|
||||
163
gerbil-manager-web/e2e/mock-api.ts
Normal file
163
gerbil-manager-web/e2e/mock-api.ts
Normal file
@@ -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<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, ',', '|'). */
|
||||
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<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'),
|
||||
}
|
||||
|
||||
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: /<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)
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
141
gerbil-manager-web/e2e/mock-data.ts
Normal file
141
gerbil-manager-web/e2e/mock-data.ts
Normal file
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user