Files
kochwas/tests/e2e/remote/ingredient-sections.spec.ts
hsiegeln c07d2f99ad
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 40s
test(e2e): Zutaten-Sektionen CRUD + UI-Flow auf kochwas-dev
4 new remote specs: API roundtrip, editor add-section + view render,
section remove, empty heading -> null on save. All 46 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 15:19:13 +02:00

220 lines
9.9 KiB
TypeScript

import { test, expect } from '@playwright/test';
import { setActiveProfile, HENDRIK_ID } from './fixtures/profile';
// Helper: idempotent recipe delete.
async function deleteRecipe(
request: Parameters<Parameters<typeof test>[1]>[0]['request'],
id: number
): Promise<void> {
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();
});