import { test, expect, type APIRequestContext } from '@playwright/test'; import { setActiveProfile, HENDRIK_ID } from './fixtures/profile'; // Helper: idempotent recipe delete. async function deleteRecipe(request: APIRequestContext, id: number): Promise { await request.delete(`/api/recipes/${id}`); } // Shared ingredient payload builder — fills all required Zod fields. function makeIngredient( position: number, name: string, section_heading: string | null, overrides: Partial<{ quantity: number | null; unit: string | null; note: string | null; raw_text: string; }> = {} ) { return { position, quantity: overrides.quantity ?? null, unit: overrides.unit ?? null, name, note: overrides.note ?? null, raw_text: overrides.raw_text ?? name, section_heading }; } // ───────────────────────────────────────────────────────────────────────────── // Per-test cleanup scaffolding — single variable, reset in beforeEach. // ───────────────────────────────────────────────────────────────────────────── let createdId: number | null = null; test.beforeEach(() => { createdId = null; }); test.afterEach(async ({ request }) => { if (createdId !== null) { await deleteRecipe(request, createdId); createdId = null; } }); // ───────────────────────────────────────────────────────────────────────────── // Test 1 — pure API roundtrip (no browser needed) // ───────────────────────────────────────────────────────────────────────────── test('API: section_heading persistiert ueber PATCH + GET', async ({ request }) => { // 1. Create blank recipe. const createRes = await request.post('/api/recipes/blank'); expect(createRes.status()).toBe(200); const { id } = (await createRes.json()) as { id: number }; createdId = id; // 2. PATCH with 3 ingredients carrying section_heading values. const patchRes = await request.patch(`/api/recipes/${id}`, { data: { ingredients: [ makeIngredient(1, 'Mehl', 'Fuer den Teig', { quantity: 200, unit: 'g', raw_text: '200 g Mehl' }), makeIngredient(2, 'Zucker', null, { quantity: 100, unit: 'g', raw_text: '100 g Zucker' }), makeIngredient(3, 'Beeren', 'Fuer die Fuellung', { quantity: 150, unit: 'g', raw_text: '150 g Beeren' }) ] } }); expect(patchRes.status()).toBe(200); // 3. GET and assert persisted values. const getRes = await request.get(`/api/recipes/${id}`); expect(getRes.status()).toBe(200); const body = (await getRes.json()) as { recipe: { ingredients: Array<{ name: string; section_heading: string | null }> }; }; const ings = body.recipe.ingredients; const mehl = ings.find((i) => i.name === 'Mehl'); const zucker = ings.find((i) => i.name === 'Zucker'); const beeren = ings.find((i) => i.name === 'Beeren'); expect(mehl?.section_heading).toBe('Fuer den Teig'); expect(zucker?.section_heading).toBeNull(); expect(beeren?.section_heading).toBe('Fuer die Fuellung'); }); // ───────────────────────────────────────────────────────────────────────────── // Test 2 — UI edit flow: add section, save, assert view renders heading // ───────────────────────────────────────────────────────────────────────────── test('Editor: Abschnitt via Inline-Button anlegen, View rendert Ueberschrift', async ({ page, request }) => { // 1. Create blank recipe via API. const createRes = await request.post('/api/recipes/blank'); expect(createRes.status()).toBe(200); const { id } = (await createRes.json()) as { id: number }; createdId = id; // 2. Open recipe in edit mode. await setActiveProfile(page, HENDRIK_ID); await page.goto(`/recipes/${id}?edit=1`); // 3. Add two ingredient rows. const addIngBtn = page.getByRole('button', { name: /Zutat hinzufügen/i }); await addIngBtn.click(); await addIngBtn.click(); // Fill the two ingredient rows by aria-label "Zutat" inputs. const nameInputs = page.locator('.ing-list .ing-row input[aria-label="Zutat"]'); await nameInputs.nth(0).fill('Mehl'); await nameInputs.nth(1).fill('Zucker'); // 4. Click "Abschnitt hinzufügen" above the first row. // The button is inside .section-insert which is opacity:0 until hover/focus. // Hover the ing-list to trigger visibility, then click. await page.hover('.ing-list'); await page.locator('.ing-list .add-section').first().click(); // 5. Type heading text into the section-heading input that appeared. const headingInput = page.locator('.ing-list input[aria-label="Sektionsüberschrift"]').first(); await headingInput.fill('Fuer den Teig'); // 6. Save — exact match to avoid colliding with "Kommentar speichern". await page.getByRole('button', { name: 'Speichern', exact: true }).click(); // After save, editMode becomes false — page switches to view mode. // Wait for the section-heading element to confirm view mode is active. await expect(page.locator('.ing-list .section-heading').first()).toBeVisible({ timeout: 8000 }); // 7. Assert heading text is rendered. await expect(page.locator('.ing-list .section-heading').first()).toHaveText('Fuer den Teig'); }); // ───────────────────────────────────────────────────────────────────────────── // Test 3 — UI: remove an existing section heading, save, confirm it's gone // ───────────────────────────────────────────────────────────────────────────── test('Editor: Sektion entfernen speichert ohne Ueberschrift', async ({ page, request }) => { // 1. Create blank recipe and pre-populate via API. const createRes = await request.post('/api/recipes/blank'); expect(createRes.status()).toBe(200); const { id } = (await createRes.json()) as { id: number }; createdId = id; await request.patch(`/api/recipes/${id}`, { data: { ingredients: [makeIngredient(1, 'Butter', 'Teig', { raw_text: 'Butter' })] } }); // 2. Open editor. await setActiveProfile(page, HENDRIK_ID); await page.goto(`/recipes/${id}?edit=1`); // The section-heading-row should be visible since heading = 'Teig'. const removeBtn = page .locator('.ing-list') .getByRole('button', { name: 'Sektion entfernen' }); await expect(removeBtn).toBeVisible({ timeout: 6000 }); // 3. Click the section-remove X button. await removeBtn.click(); // 4. Save — exact match to avoid colliding with "Kommentar speichern". await page.getByRole('button', { name: 'Speichern', exact: true }).click(); // Wait for view mode (editMode = false makes RecipeEditor unmount). // The .section-heading-row is part of the editor; in view mode we check // the view's .ing-list for absence of .section-heading items. await expect(page.locator('.ing-list .section-heading')).toHaveCount(0, { timeout: 8000 }); }); // ───────────────────────────────────────────────────────────────────────────── // Test 4 — empty heading trims to null on save // ───────────────────────────────────────────────────────────────────────────── test('Editor: leeres Heading wird beim Speichern zu null', async ({ page, request }) => { // 1. Create blank recipe. const createRes = await request.post('/api/recipes/blank'); expect(createRes.status()).toBe(200); const { id } = (await createRes.json()) as { id: number }; createdId = id; // 2. Open editor, add one ingredient, open section input and leave it empty. await setActiveProfile(page, HENDRIK_ID); await page.goto(`/recipes/${id}?edit=1`); await page.getByRole('button', { name: /Zutat hinzufügen/i }).click(); await page.locator('.ing-list .ing-row input[aria-label="Zutat"]').first().fill('Eier'); // Trigger add-section visibility and click. await page.hover('.ing-list'); await page.locator('.ing-list .add-section').first().click(); // Leave the heading input empty (do not type anything). // The save() function trims '' → null. // 3. Save — exact match to avoid colliding with "Kommentar speichern". await page.getByRole('button', { name: 'Speichern', exact: true }).click(); // Wait until view mode is active (editor gone). await expect(page.locator('.ing-list .section-heading')).toHaveCount(0, { timeout: 8000 }); // 4. Confirm via API that section_heading is null. const getRes = await request.get(`/api/recipes/${id}`); expect(getRes.status()).toBe(200); const body = (await getRes.json()) as { recipe: { ingredients: Array<{ name: string; section_heading: string | null }> }; }; const eier = body.recipe.ingredients.find((i) => i.name === 'Eier'); expect(eier?.section_heading).toBeNull(); });