From 791e9f91cc2534b16bd2bf70297f67d77ada937a Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 15:16:38 +0200 Subject: [PATCH] QA-2: Abgabe-Wizard e2e (ForSale-Seed, sale-ad+contracts Mock, abgabe.spec.ts) --- gerbil-manager-web/e2e/abgabe.spec.ts | 127 ++++++++++++++++++++++++++ gerbil-manager-web/e2e/mock-api.ts | 48 ++++++++++ gerbil-manager-web/e2e/mock-data.ts | 31 +++++++ 3 files changed, 206 insertions(+) create mode 100644 gerbil-manager-web/e2e/abgabe.spec.ts diff --git a/gerbil-manager-web/e2e/abgabe.spec.ts b/gerbil-manager-web/e2e/abgabe.spec.ts new file mode 100644 index 0000000..8834292 --- /dev/null +++ b/gerbil-manager-web/e2e/abgabe.spec.ts @@ -0,0 +1,127 @@ +/** + * 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 Großbecken) → skipUnlessMock. + */ +import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures' + +const ta = de.pages.abgabe +const tw = de.pages.vertraege.wizard +const tk = de.pages.kontakte + +test.describe('Abgabe', () => { + test('Abgabe-Seite zeigt ForSale-Tiere gruppiert nach Becken', 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 sichtbar sein + await expect(page.getByText('Balu Abgabe')).toBeVisible() + await expect(page.getByText('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({ hasText: 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.getByText('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.getByText('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.getByText('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.getByText(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.getByText(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.getByText(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 Validierungsmeldung', async ({ page }) => { + skipUnlessMock() + await page.goto('/vertraege/neu?tiere=sale-balu') + await expect(page.getByText(tw.pickContact)).toBeVisible() + // Ohne Auswahl direkt auf Weiter klicken + await page.getByRole('button', { name: tw.next }).click() + await expect(page.getByText(tw.pickContact, { exact: true })).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 page.getByRole('button', { name: tw.next }).click() + // Schritt 3: leeres Preisfeld -> Fehler + await page.getByRole('button', { name: tw.next }).click() + await expect(page.getByText(tw.priceInvalid)).toBeVisible() + }) +}) diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index dbceadf..1e74a7a 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -101,6 +101,7 @@ export async function installMockApi(page: Page): Promise { '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 { 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 { } } + // 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: / und // m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) const col = m ? collections[m[1]] : undefined diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index f481ba1..a3bd834 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -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,19 @@ 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 Großbecken (Gruppe 1 für den Abgabe-Wizard-Test) + { + ...gerbil('sale-balu', 'Balu Abgabe', 'male', '2024-03-12', null, 'cv-agouti'), + status: 'ForSale' as const, + enclosureId: 'enc-gross', + 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-gross', + notes: 'neugieriger Entdecker', + }, ] const litters: Litter[] = [ @@ -258,5 +287,7 @@ export function seedDb(): MockDb { requests, aiConfigured: true, mailConfigured: true, + contracts: [], + saleAdConfigured: true, } }