diff --git a/tests/e2e/remote/ingredient-sections.spec.ts b/tests/e2e/remote/ingredient-sections.spec.ts new file mode 100644 index 0000000..7bad458 --- /dev/null +++ b/tests/e2e/remote/ingredient-sections.spec.ts @@ -0,0 +1,219 @@ +import { test, expect } from '@playwright/test'; +import { setActiveProfile, HENDRIK_ID } from './fixtures/profile'; + +// Helper: idempotent recipe delete. +async function deleteRecipe( + request: Parameters[1]>[0]['request'], + 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(); +});