Merge feature/qa-2: Verbatim-few-shots (POLISH-2), CI-Flake-Fix, skipUnlessLive+Live-Smoke, Abgabe-Wizard-e2e [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled

This commit is contained in:
2026-06-06 15:34:38 +02:00
9 changed files with 304 additions and 22 deletions

View File

@@ -0,0 +1,129 @@
/**
* QA-2: Abgabe-Wizard (FEAT-12a + FEAT-13) — Abgabetiere gruppieren,
* KI-Inserat generieren, und Vertrag-Handoff via ?tiere= Query-Parameter.
*
* Mock-gebunden (Seed: 2 ForSale-Tiere im Quarantänebecken) → skipUnlessMock.
*/
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const ta = de.pages.abgabe
const tw = de.pages.vertraege.wizard
test.describe('Abgabe', () => {
test('Abgabe-Seite zeigt ForSale-Tiere und Inserat-Vorschau', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.forSale)
await expect(page.getByRole('heading', { name: ta.title, exact: true })).toBeVisible()
await expect(page.getByText(ta.grouping.title)).toBeVisible()
// Die beiden ForSale-Tiere müssen als Tiername (strong-Element im Komposer) sichtbar sein
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Benny Abgabe' })).toBeVisible()
// Inserat-Vorschau (listing-preview) wird gerendert
await expect(page.locator('.listing-preview').first()).toBeVisible()
// Status-Dropdown standard: FREI
await expect(
page.locator('label.field').filter({ has: page.locator(`span:text-is("${ta.listing.status}")`) }).locator('select').first()
).toHaveValue('free')
})
test('KI-Inserat: "Text mit KI verbessern" — Mock liefert Inserat-Text', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.forSale)
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
await page.getByRole('button', { name: ta.ai.improve }).first().click()
// Mock antwortet synchron; der generierte Text erscheint in der Vorschau
await expect(page.locator('.listing-preview').first()).toContainText('Generierter Inserat-Text')
})
test('KI-Inserat: 503 zeigt deutschen Hinweis (saleAdConfigured=false)', async ({ page, mockDb }) => {
skipUnlessMock()
mockDb!.saleAdConfigured = false
await gotoSection(page, de.nav.forSale)
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
await page.getByRole('button', { name: ta.ai.improve }).first().click()
await expect(page.getByText(ta.ai.notConfigured)).toBeVisible()
})
test('Vertrag-Handoff: "Abgabe abschließen" öffnet Wizard mit vorausgewählten Tieren', async ({
page,
}) => {
skipUnlessMock()
await gotoSection(page, de.nav.forSale)
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
// Wizard öffnen (navigiert zu /vertraege/neu?tiere=sale-balu,sale-benny)
await page.getByRole('button', { name: ta.export.finish }).first().click()
await expect(page).toHaveURL(/\/vertraege\/neu\?tiere=/)
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
// Schritt 1: Abnehmer auswählen
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
})
})
test.describe('Vertrag-Wizard', () => {
test('4-Schritte-Wizard: Abnehmer → Tiere → Preis → Vertrag erzeugen', async ({ page }) => {
skipUnlessMock()
// Direkt mit vorausgewählten Tieren öffnen (wie über Abgabe-Seite)
await page.goto('/vertraege/neu?tiere=sale-balu,sale-benny')
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
// Schritt 1: Abnehmer wählen — Zoohandlung Meier aus dem Seed
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 2: Tiere — Vorauswahl aus ?tiere= ist aktiv
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
// Beide ForSale-Tiere sind vorausgewählt (Checkboxen angehakt)
await expect(
page.locator('.wizard-pick').filter({ hasText: 'Balu Abgabe' }).getByRole('checkbox')
).toBeChecked()
await expect(
page.locator('.wizard-pick').filter({ hasText: 'Benny Abgabe' }).getByRole('checkbox')
).toBeChecked()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 3: Preis & Datum
await page.getByLabel(tw.priceLabel).fill('50,00')
await page.getByRole('button', { name: tw.next }).click()
// Schritt 4: Zusammenfassung + Erzeugen
await expect(page.getByText(tw.summaryTitle)).toBeVisible()
await expect(page.getByText('Zoohandlung Meier')).toBeVisible()
await expect(page.getByText('Balu Abgabe')).toBeVisible()
await page.getByRole('button', { name: tw.generate }).click()
// Erfolg: Vertrag erstellt, Download-Link vorhanden
await expect(page.getByRole('heading', { name: tw.successTitle })).toBeVisible()
await expect(page.getByRole('link', { name: tw.downloadDocx })).toBeVisible()
})
test('Schritt 1: fehlender Abnehmer zeigt Validierungsfehler', async ({ page }) => {
skipUnlessMock()
await page.goto('/vertraege/neu?tiere=sale-balu')
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
// Ohne Auswahl direkt auf Weiter klicken → Fehler-Alert erscheint
await page.getByRole('button', { name: tw.next }).click()
// Alert enthält den Hinweis (stepError), Schritt bleibt auf 1
await expect(page.locator('.alert--error').filter({ hasText: tw.pickContact })).toBeVisible()
})
test('Schritt 3: ungültiger Preis zeigt Validierungsmeldung', async ({ page }) => {
skipUnlessMock()
await page.goto('/vertraege/neu?tiere=sale-balu')
// Schritt 1: Abnehmer wählen
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 2: Tiere bestätigen
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 3: leeres Preisfeld -> Fehler
await expect(page.getByLabel(tw.priceLabel)).toBeVisible()
await page.getByRole('button', { name: tw.next }).click()
await expect(page.locator('.alert--error').filter({ hasText: tw.priceInvalid })).toBeVisible()
})
})

View File

@@ -72,7 +72,13 @@ test.describe('Kontakte', () => {
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
await gotoSection(page, de.nav.contacts)
// Explizit auf die Listenüberschrift warten, bevor wir klicken — verhindert den
// CI-Flake, der auftrat wenn der Vite-Server unter paralleler Last noch nicht
// alle Routen der Seite gerendert hatte (gotoSection klickt nur den Nav-Link,
// wartet aber nicht auf den vollständigen Seitenaufbau).
await expect(page.getByRole('heading', { name: tk.title, exact: true })).toBeVisible()
await page.getByRole('link', { name: tk.newButton }).click()
await expect(page.getByRole('heading', { name: tk.form.createTitle })).toBeVisible()
const name = uniqueName('Kontakt')
await page.getByLabel(`${tk.fields.name} *`).fill(name)
// FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten

View File

@@ -17,5 +17,10 @@ test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer w
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
await page.goto('/abgabe')
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
const kruemelSection = page.locator('.group-composer__animal').filter({
has: page.locator('strong', { hasText: 'Krümel' }),
})
await expect(kruemelSection.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
})

View File

@@ -39,6 +39,17 @@ export function skipUnlessMock() {
test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)')
}
/**
* Test überspringen im Mock-Modus — nur im LIVE-Modus sinnvoll.
* Live-Tests MÜSSEN Ergebnisse assertieren (z. B. Liste nicht leer,
* konkreter Wert sichtbar), nicht nur HTTP-200. Lesson: der Mock spiegelt
* den Frontend-Code, nicht das echte Backend — ein Bug bleibt unsichtbar,
* wenn wir nur testen, ob die Seite lädt.
*/
export function skipUnlessLive() {
test.skip(!LIVE, 'nur im LIVE-Modus sinnvoll (E2E_BASE_URL setzen)')
}
/**
* Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken,
* Kontakte und Statistik hinter dem „Mehr“-Blatt.

View File

@@ -0,0 +1,57 @@
/**
* QA-2: Live-Modus-Smoke — läuft NUR gegen das echte Backend (E2E_BASE_URL).
*
* Lektion (aus dem Such-Bug): Mock-Tests spiegeln nur den Frontend-Code.
* Ein Backend-Bug (falscher Filter, leere Antwort, fehlende Migration) bleibt
* unsichtbar, wenn Live-Tests nur "Seite lädt" prüfen. Diese Tests assertieren
* ERGEBNISSE: Liste nicht leer, Suche liefert Treffer, Detail zeigt Stammdaten
* und Eltern-Links.
*
* Voraussetzung für den Eltern-Link-Test: Re-Import #2 durchgeführt
* (322 Tiere, 882 Würfe live; mindestens ein Wurf hat beide Eltern).
*/
import { de, expect, gotoSection, skipUnlessLive, test } from './fixtures'
const t = de.pages.gerbils
const tl = de.pages.litters
test('Live: Tiere-Liste enthält nach Re-Import mindestens 10 Einträge', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Leere Liste = Backend-Bug (falscher Filter, fehlende DB-Migration o. ä.)
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible()
expect(await rows.count()).toBeGreaterThanOrEqual(10)
})
test('Live: Namenssuche gibt Treffer zurück — nicht nur HTTP 200', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Kurzer Prefix: bei 322 Tieren mindestens 1 Treffer. Kein Treffer = Gridify-Bug.
await page.getByPlaceholder(t.searchPlaceholder).first().fill('a')
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
expect(await rows.count()).toBeGreaterThanOrEqual(1)
})
test('Live: Wurf-Detail zeigt Elterntier-Links (mindestens Vater oder Mutter)', async ({
page,
}) => {
skipUnlessLive()
// Nach Re-Import #2 hat mindestens Gerbil C (Victoria) beide Eltern.
// Der Wurf-Detail ist die einfachste Ansicht, die Eltern-Links rendert.
await gotoSection(page, de.nav.litters)
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible()
// Mindestens 1 Wurf in der Liste; ersten öffnen.
const firstLink = page.getByRole('link').filter({ hasText: /.+/ }).nth(1)
await expect(firstLink).toBeVisible()
await firstLink.click()
// Detailseite: Elterntier-Abschnitt ist sichtbar
await expect(page.getByText(tl.detail.parents)).toBeVisible()
// Mindestens ein Link führt zu einem Tier — bei fehlendem Import fehlt dieser Link
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
})

View File

@@ -101,6 +101,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
'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) => {
@@ -205,6 +206,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
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') {
@@ -313,6 +333,34 @@ export async function installMockApi(page: Page): Promise<MockDb> {
}
}
// 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)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined

View File

@@ -44,6 +44,19 @@ export interface MockPage {
blocks: MockBlock[]
}
/** ABGABE: Abgabevertrag (mirrors SaleContract in src/api/contracts.ts). */
export interface MockContract {
id: string
contactId: string
price: number
handoverDate: string
contractDate: string
fileName: string
createdAt: string
gerbilIds: string[]
url: string
}
export interface MockDb {
gerbils: Gerbil[]
litters: Litter[]
@@ -58,6 +71,9 @@ export interface MockDb {
requests: InboxRequest[]
aiConfigured: boolean
mailConfigured: boolean
// ABGABE: Verträge + KI-Inserat-Flag
contracts: MockContract[]
saleAdConfigured: boolean
}
function gerbil(
@@ -119,6 +135,20 @@ export function seedDb(): MockDb {
{ ...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' },
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
{
...gerbil('sale-balu', 'Balu Abgabe', 'male', '2024-03-12', null, 'cv-agouti'),
status: 'ForSale' as const,
enclosureId: 'enc-leer',
notes: 'ruhig, nimmt Leckerlis aus der Hand',
},
{
...gerbil('sale-benny', 'Benny Abgabe', 'male', '2024-03-12', null, 'cv-schwarz-schecke'),
status: 'ForSale' as const,
enclosureId: 'enc-leer',
notes: 'neugieriger Entdecker',
},
]
const litters: Litter[] = [
@@ -258,5 +288,7 @@ export function seedDb(): MockDb {
requests,
aiConfigured: true,
mailConfigured: true,
contracts: [],
saleAdConfigured: true,
}
}