test(e2e): Zutaten-Sektionen CRUD + UI-Flow auf kochwas-dev
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 40s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 40s
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>
This commit is contained in:
219
tests/e2e/remote/ingredient-sections.spec.ts
Normal file
219
tests/e2e/remote/ingredient-sections.spec.ts
Normal file
@@ -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<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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user