diff --git a/.gitignore b/.gitignore index 8393e1d..9b63ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ data/ *.log test-results/ playwright-report/ +playwright-report-remote/ .playwright-mcp/ diff --git a/package.json b/package.json index b6d9b86..21d24d3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "format": "prettier --write .", "render:icons": "node scripts/render-icons.mjs", "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui" + "test:e2e:ui": "playwright test --ui", + "test:e2e:remote": "playwright test --config=playwright.remote.config.ts" }, "devDependencies": { "@playwright/test": "^1.59.1", diff --git a/playwright.config.ts b/playwright.config.ts index ff9e209..465ff0b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,6 +5,7 @@ import { defineConfig } from '@playwright/test'; // Preview-Server (kein Dev-Server, damit der SW registrierbar ist). export default defineConfig({ testDir: 'tests/e2e', + testIgnore: ['tests/e2e/remote/**'], fullyParallel: false, reporter: 'list', use: { diff --git a/playwright.remote.config.ts b/playwright.remote.config.ts new file mode 100644 index 0000000..3d9619c --- /dev/null +++ b/playwright.remote.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from '@playwright/test'; + +// Zweite Playwright-Config fuer E2E-Smoketests gegen ein deployed +// Environment (standardmaessig kochwas-dev.siegeln.net). +// +// Getrennt von playwright.config.ts, weil diese Tests: +// - keinen lokalen Preview-Server starten +// - gegen eine echte Datenbank laufen (daher workers: 1, afterEach-Cleanup) +// - Service-Worker-Lifecycle nicht manipulieren (das macht offline.spec.ts lokal) +// +// Ausfuehrung: npm run test:e2e:remote +// Ziel-URL ueberschreiben: E2E_REMOTE_URL=https://... npm run test:e2e:remote +const BASE_URL = process.env.E2E_REMOTE_URL ?? 'https://kochwas-dev.siegeln.net'; + +export default defineConfig({ + testDir: 'tests/e2e/remote', + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report-remote' }]], + use: { + baseURL: BASE_URL, + headless: true, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + // Service-Worker zulassen, aber keine Offline-Manipulation — die + // Tests hier pruefen Live-Verhalten gegen den Server. + serviceWorkers: 'allow' + } +}); diff --git a/tests/e2e/remote/README.md b/tests/e2e/remote/README.md new file mode 100644 index 0000000..f5efc82 --- /dev/null +++ b/tests/e2e/remote/README.md @@ -0,0 +1,68 @@ +# E2E-Tests gegen kochwas-dev + +Playwright-Smoketests gegen ein deployed Environment — standardmaessig +`https://kochwas-dev.siegeln.net`. Loest die bisherigen manuellen +MCP-Runs ab. + +## Setup (einmalig) + +```bash +npm install +npx playwright install chromium +``` + +## Ausfuehren + +```bash +npm run test:e2e:remote # Headless, alle Tests +npm run test:e2e:remote -- --ui # Mit Playwright-UI (Trace-Viewer) +npm run test:e2e:remote -- --debug # Step-by-Step +``` + +Alternative URL: + +```bash +E2E_REMOTE_URL=https://kochwas.siegeln.net npm run test:e2e:remote +``` + +## Was abgedeckt ist + +### Happy Paths (UI) + +| Spec | Was | +|---|---| +| `homepage.spec.ts` | H1, Recents/Alle-Rezepte-Sektionen, Sort-Tabs rendern unterschiedlich, keine Console-Errors | +| `search.spec.ts` | Lokaler Treffer, Web-Fallback, Empty-State, Deep-Link `?q=` | +| `profile.spec.ts` | Switcher-Dialog, Auswahl persistiert, "Deine Favoriten" erscheint nach Login | +| `recipe-detail.spec.ts` | Header, Portionen-Skalierung (4->6, Mengen proportional), Favorit-Toggle, Rating persistiert ueber Reload, Gekocht-Counter, Wunschliste-Toggle | +| `comments.spec.ts` | Eigenen Kommentar erstellen + via UI-Button loeschen; fremder Kommentar hat keinen Delete-Button | +| `wishlist.spec.ts` | Seite laedt, Sort-Tabs, Header-Badge spiegelt API-Zaehler | +| `preview.spec.ts` | Guard ohne `?url=`, echte URL laedt JSON-LD-Parsing, unparsbare URL zeigt error-box | +| `admin.spec.ts` | Alle 4 Admin-Subrouten laden mit Tab-Nav, `/admin` redirected | + +### Negative Paths (API) + +| Spec | Was | +|---|---| +| `api-errors.spec.ts` | `parsePositiveIntParam` → 400 `Invalid id` (4 Call-Sites), `validateBody` → 400 `{message, issues}` (4 Call-Sites), 404 auf missing Ressource, Positiv-Sanity fuer /health, /profiles, /domains | + +## Design-Entscheidungen + +**`workers: 1`.** Tests mutieren echte Daten auf `kochwas-dev` (Rating, +Favorit, Wunschliste, Kommentare). Parallelitaet wuerde Race-Conditions +geben. `afterEach` raeumt per API auf — idempotent. + +**Hardcoded Test-Fixtures.** Rezept-ID 66 (Chicken Teriyaki) und +Profile 1/2/3 (Hendrik/Verena/Leana) sind stabil auf dev. Bei +DB-Reset muessen ggf. die Konstanten angepasst werden. + +**Kein Build/Server-Start.** Im Gegensatz zur lokalen `playwright.config.ts` +startet diese Config keinen Preview-Server — die Tests laufen gegen das +CI-Build auf dev. + +## Was NICHT hier ist + +- **Service-Worker-Lifecycle / Offline** → `tests/e2e/offline.spec.ts` (lokal). +- **Bild-Upload** — File-Dialog + echte Dateien; nur manuell sinnvoll. +- **Drucken** — oeffnet `window.print()`, headless unzuverlaessig. +- **Sync unter Last** — braucht dediziertes Harness, nicht Smoke-Scope. diff --git a/tests/e2e/remote/admin.spec.ts b/tests/e2e/remote/admin.spec.ts new file mode 100644 index 0000000..7b99c72 --- /dev/null +++ b/tests/e2e/remote/admin.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Admin-Routen', () => { + const SUBROUTES = ['domains', 'profiles', 'backup', 'app'] as const; + + for (const sub of SUBROUTES) { + test(`/admin/${sub} laedt mit Nav-Tabs`, async ({ page }) => { + await page.goto(`/admin/${sub}`); + // Alle Admin-Subseiten haben dieselbe Tab-Leiste. + for (const label of ['Domains', 'Profile', 'Backup', 'App']) { + await expect(page.getByRole('link', { name: label })).toBeVisible(); + } + }); + } + + test('/admin redirected auf /admin/domains', async ({ page }) => { + await page.goto('/admin'); + await expect(page).toHaveURL(/\/admin\/domains$/); + }); +}); diff --git a/tests/e2e/remote/api-errors.spec.ts b/tests/e2e/remote/api-errors.spec.ts new file mode 100644 index 0000000..a445a73 --- /dev/null +++ b/tests/e2e/remote/api-errors.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from '@playwright/test'; + +// Negative-Path Tests fuer die api-helpers: parsePositiveIntParam und +// validateBody. Jeder neue API-Handler sollte dieselben Error-Shapes +// liefern — wenn dieser Suite-Block kippt, ist der Helper-Contract kaputt. + +test.describe('API Error-Shapes', () => { + test.describe('parsePositiveIntParam', () => { + test('GET /api/recipes/abc -> 400 Invalid id', async ({ request }) => { + const r = await request.get('/api/recipes/abc'); + expect(r.status()).toBe(400); + expect(await r.json()).toEqual({ message: 'Invalid id' }); + }); + + test('GET /api/recipes/-1 -> 400 Invalid id', async ({ request }) => { + const r = await request.get('/api/recipes/-1'); + expect(r.status()).toBe(400); + expect(await r.json()).toEqual({ message: 'Invalid id' }); + }); + + test('GET /api/recipes/0 -> 400 Invalid id', async ({ request }) => { + const r = await request.get('/api/recipes/0'); + expect(r.status()).toBe(400); + expect(await r.json()).toEqual({ message: 'Invalid id' }); + }); + + test('POST /api/recipes/abc/comments -> 400 Invalid id', async ({ request }) => { + const r = await request.post('/api/recipes/abc/comments', { data: {} }); + expect(r.status()).toBe(400); + expect(await r.json()).toEqual({ message: 'Invalid id' }); + }); + }); + + test.describe('validateBody', () => { + test('POST /api/wishlist leer -> 400 {message, issues}', async ({ request }) => { + const r = await request.post('/api/wishlist', { data: {} }); + expect(r.status()).toBe(400); + const body = (await r.json()) as { message: string; issues?: unknown[] }; + expect(body.message).toBe('Invalid body'); + expect(Array.isArray(body.issues)).toBe(true); + expect((body.issues ?? []).length).toBeGreaterThanOrEqual(2); // recipe_id + profile_id + }); + + test('POST /api/recipes/66/comments leer -> 400 {message, issues}', async ({ request }) => { + const r = await request.post('/api/recipes/66/comments', { data: {} }); + expect(r.status()).toBe(400); + const body = (await r.json()) as { message: string; issues?: unknown[] }; + expect(body.message).toBe('Invalid body'); + expect((body.issues ?? []).length).toBeGreaterThanOrEqual(1); // profile_id oder text + }); + + test('PUT /api/recipes/66/favorite leer -> 400 {message, issues}', async ({ request }) => { + const r = await request.put('/api/recipes/66/favorite', { data: {} }); + expect(r.status()).toBe(400); + const body = (await r.json()) as { message: string; issues?: unknown[] }; + expect(body.message).toBe('Invalid body'); + expect((body.issues ?? []).length).toBeGreaterThanOrEqual(1); + }); + + test('POST /api/domains leer -> 400 {message, issues}', async ({ request }) => { + const r = await request.post('/api/domains', { data: {} }); + expect(r.status()).toBe(400); + const body = (await r.json()) as { message: string; issues?: unknown[] }; + expect(body.message).toBe('Invalid body'); + expect((body.issues ?? []).length).toBeGreaterThanOrEqual(1); + }); + }); + + test.describe('404 auf missing Ressourcen', () => { + test('GET /api/recipes/99999 -> 404 Recipe not found', async ({ request }) => { + const r = await request.get('/api/recipes/99999'); + expect(r.status()).toBe(404); + expect(await r.json()).toEqual({ message: 'Recipe not found' }); + }); + }); + + test.describe('Positive Sanity-Checks', () => { + test('GET /api/health -> 200 mit db:"ok"', async ({ request }) => { + const r = await request.get('/api/health'); + expect(r.status()).toBe(200); + const body = (await r.json()) as { db: string }; + expect(body.db).toBe('ok'); + }); + + test('GET /api/profiles -> drei Profile', async ({ request }) => { + const r = await request.get('/api/profiles'); + expect(r.status()).toBe(200); + const body = (await r.json()) as { id: number; name: string }[]; + expect(body.length).toBeGreaterThanOrEqual(3); + const names = body.map((p) => p.name).sort(); + expect(names).toEqual(expect.arrayContaining(['Hendrik', 'Leana', 'Verena'])); + }); + + test('GET /api/domains -> liefert Array', async ({ request }) => { + const r = await request.get('/api/domains'); + expect(r.status()).toBe(200); + const body = await r.json(); + expect(Array.isArray(body)).toBe(true); + }); + }); +}); diff --git a/tests/e2e/remote/comments.spec.ts b/tests/e2e/remote/comments.spec.ts new file mode 100644 index 0000000..b562659 --- /dev/null +++ b/tests/e2e/remote/comments.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test'; +import { setActiveProfile, HENDRIK_ID } from './fixtures/profile'; +import { cleanupE2EComments, deleteComment } from './fixtures/api-cleanup'; + +const RECIPE_ID = 66; + +test.describe('Kommentare', () => { + test.beforeEach(async ({ page, request }) => { + await setActiveProfile(page, HENDRIK_ID); + // Stray E2E-Kommentare aus abgebrochenen Runs wegraeumen. + await cleanupE2EComments(request, RECIPE_ID, HENDRIK_ID); + }); + + test.afterEach(async ({ request }) => { + await cleanupE2EComments(request, RECIPE_ID, HENDRIK_ID); + }); + + test('Kommentar erstellen, Delete-Button erscheint, Loeschen via UI', async ({ + page + }) => { + const unique = `E2E ${Date.now()}`; + await page.goto(`/recipes/${RECIPE_ID}`); + + await page.getByRole('textbox').filter({ hasText: '' }).last().fill(unique); + await page.getByRole('button', { name: 'Kommentar speichern' }).click(); + + // Neuer Kommentar sichtbar + await expect(page.getByText(unique)).toBeVisible({ timeout: 5000 }); + + // Delete-Button NUR beim eigenen Kommentar + const delBtn = page.getByRole('button', { name: 'Kommentar löschen' }); + await expect(delBtn).toBeVisible(); + + await delBtn.click(); + // ConfirmDialog "Kommentar loeschen?" mit Loeschen-Button + await expect(page.getByRole('heading', { name: /Kommentar löschen/i })).toBeVisible(); + await page.getByRole('button', { name: 'Löschen' }).click(); + + await expect(page.getByText(unique)).not.toBeVisible({ timeout: 5000 }); + }); + + test('Fremder Kommentar zeigt KEINEN Delete-Button fuers aktuelle Profil', async ({ + page, + request + }) => { + // Wir legen den Kommentar fuer ein anderes Profil (Leana, id=3) per API an. + const text = `E2E fremd ${Date.now()}`; + const res = await request.post(`/api/recipes/${RECIPE_ID}/comments`, { + data: { profile_id: 3, text } + }); + expect(res.status()).toBe(201); + const { id } = (await res.json()) as { id: number }; + + try { + await page.goto(`/recipes/${RECIPE_ID}`); + const item = page + .locator('.comments li') + .filter({ hasText: text }); + await expect(item).toBeVisible(); + await expect( + item.getByRole('button', { name: 'Kommentar löschen' }) + ).toHaveCount(0); + } finally { + await deleteComment(request, RECIPE_ID, id); + } + }); +}); diff --git a/tests/e2e/remote/fixtures/api-cleanup.ts b/tests/e2e/remote/fixtures/api-cleanup.ts new file mode 100644 index 0000000..9c20297 --- /dev/null +++ b/tests/e2e/remote/fixtures/api-cleanup.ts @@ -0,0 +1,67 @@ +import type { APIRequestContext } from '@playwright/test'; + +// Cleanup-Helfer fuer afterEach-Hooks. Alle sind idempotent — wenn der +// Zustand schon weg ist (z. B. der Test ist zwischen Action und Check +// abgebrochen), fliegt nichts. + +export async function clearRating( + api: APIRequestContext, + recipeId: number, + profileId: number +): Promise { + await api.delete(`/api/recipes/${recipeId}/rating`, { + data: { profile_id: profileId } + }); +} + +export async function clearFavorite( + api: APIRequestContext, + recipeId: number, + profileId: number +): Promise { + await api.delete(`/api/recipes/${recipeId}/favorite`, { + data: { profile_id: profileId } + }); +} + +export async function removeFromWishlist( + api: APIRequestContext, + recipeId: number, + profileId: number +): Promise { + await api.delete(`/api/wishlist/${recipeId}?profile_id=${profileId}`); +} + +export async function deleteComment( + api: APIRequestContext, + recipeId: number, + commentId: number +): Promise { + await api.delete(`/api/recipes/${recipeId}/comments`, { + data: { comment_id: commentId } + }); +} + +/** + * Safety-Net: loescht alle E2E-Kommentare eines Profils. Gedacht fuer + * afterEach/afterAll, falls ein Test abbricht bevor der eigene Cleanup + * greift. Markiert E2E-Kommentare am Prefix "E2E ". + */ +export async function cleanupE2EComments( + api: APIRequestContext, + recipeId: number, + profileId: number +): Promise { + const res = await api.get(`/api/recipes/${recipeId}/comments`); + if (!res.ok()) return; + const list = (await res.json()) as { + id: number; + profile_id: number; + text: string; + }[]; + for (const c of list) { + if (c.profile_id === profileId && c.text.startsWith('E2E ')) { + await deleteComment(api, recipeId, c.id); + } + } +} diff --git a/tests/e2e/remote/fixtures/profile.ts b/tests/e2e/remote/fixtures/profile.ts new file mode 100644 index 0000000..e33db8f --- /dev/null +++ b/tests/e2e/remote/fixtures/profile.ts @@ -0,0 +1,26 @@ +import type { Page } from '@playwright/test'; + +// Profil-IDs auf kochwas-dev: 1 = Hendrik, 2 = Verena, 3 = Leana. +// Die Tests hardcoden Hendrik als Standard, weil die Dev-DB diese +// Profile stabil enthaelt. +export const HENDRIK_ID = 1; +export const VERENA_ID = 2; +export const LEANA_ID = 3; + +/** + * Setzt das aktive Profil in localStorage, BEVOR die Seite geladen wird. + * addInitScript laeuft vor jedem Skript der Seite — damit ist das Profil + * schon da, wenn profileStore.load() das erste Mal liest. + */ +export async function setActiveProfile(page: Page, id: number): Promise { + await page.addInitScript( + (pid) => window.localStorage.setItem('kochwas.activeProfileId', String(pid)), + id + ); +} + +export async function clearActiveProfile(page: Page): Promise { + await page.addInitScript(() => + window.localStorage.removeItem('kochwas.activeProfileId') + ); +} diff --git a/tests/e2e/remote/homepage.spec.ts b/tests/e2e/remote/homepage.spec.ts new file mode 100644 index 0000000..c48bfdc --- /dev/null +++ b/tests/e2e/remote/homepage.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Startseite', () => { + test('laedt mit H1, Zuletzt-hinzugefuegt und Alle-Rezepte', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/Kochwas/); + await expect(page.getByRole('heading', { level: 1, name: 'Kochwas' })).toBeVisible(); + await expect( + page.getByRole('heading', { level: 2, name: 'Zuletzt hinzugefügt' }) + ).toBeVisible(); + await expect(page.getByRole('heading', { level: 2, name: 'Alle Rezepte' })).toBeVisible(); + }); + + test('Sort-Tabs rendern unterschiedliche Top-Eintraege', async ({ page }) => { + await page.goto('/'); + // Liste unter "Alle Rezepte" + const allSection = page.locator('section', { has: page.getByRole('heading', { name: 'Alle Rezepte' }) }); + const firstItem = () => allSection.locator('li a').first().innerText(); + + await page.getByRole('tab', { name: 'Name' }).click(); + await page.waitForTimeout(400); + const nameTop = await firstItem(); + + await page.getByRole('tab', { name: 'Hinzugefügt' }).click(); + await page.waitForTimeout(400); + const addedTop = await firstItem(); + + expect(nameTop).not.toEqual(addedTop); + }); + + test('hat keine Console-Errors', async ({ page }) => { + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + // 404s auf externen Bildern (chefkoch-cdn, cloudfront) ignorieren — + // das ist kein App-Fehler, sondern externe Thumbnails. + const appErrors = errors.filter((e) => !/Failed to load resource/i.test(e)); + expect(appErrors).toEqual([]); + }); +}); diff --git a/tests/e2e/remote/preview.spec.ts b/tests/e2e/remote/preview.spec.ts new file mode 100644 index 0000000..bf5b05e --- /dev/null +++ b/tests/e2e/remote/preview.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Preview-Route', () => { + test('ohne ?url= zeigt Guard-Fehlermeldung', async ({ page }) => { + await page.goto('/preview'); + await expect(page.getByText(/Kein \?url=-Parameter/)).toBeVisible(); + await expect(page.getByRole('heading', { name: /kein Rezept/i })).toBeVisible(); + }); + + test('mit echter URL laedt Vorschau + Speichern-Button', async ({ page }) => { + const u = encodeURIComponent('https://emmikochteinfach.de/chicken-teriyaki/'); + await page.goto(`/preview?url=${u}`); + await expect(page.getByText('Vorschau — noch nicht gespeichert')).toBeVisible({ + timeout: 20000 + }); + await expect(page.getByRole('button', { name: /speichern/i })).toBeVisible(); + // Zutaten aus dem JSON-LD sollten geparst sein. + await expect(page.getByText(/Hähnchenbrustfilet/i).first()).toBeVisible(); + }); + + test('mit unparsbarer URL zeigt error-box', async ({ page }) => { + // google.com hat kein Recipe-JSON-LD -> Parser-Fehler. + const u = encodeURIComponent('https://www.google.com'); + await page.goto(`/preview?url=${u}`); + await expect(page.getByRole('heading', { name: /kein Rezept/i })).toBeVisible({ + timeout: 20000 + }); + }); +}); diff --git a/tests/e2e/remote/profile.spec.ts b/tests/e2e/remote/profile.spec.ts new file mode 100644 index 0000000..08055cf --- /dev/null +++ b/tests/e2e/remote/profile.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; +import { clearActiveProfile, setActiveProfile, HENDRIK_ID } from './fixtures/profile'; + +test.describe('Profil', () => { + test('Switcher zeigt alle 3 Profile', async ({ page }) => { + await clearActiveProfile(page); + await page.goto('/'); + await page.getByRole('button', { name: 'Profil wechseln' }).click(); + await expect(page.getByText('Wer kocht heute?')).toBeVisible(); + for (const name of ['Hendrik', 'Verena', 'Leana']) { + await expect( + page.locator('.profile-btn', { hasText: name }) + ).toBeVisible(); + } + }); + + test('Profil-Auswahl persistiert im Header', async ({ page }) => { + await clearActiveProfile(page); + await page.goto('/'); + await page.getByRole('button', { name: 'Profil wechseln' }).click(); + await page.locator('.profile-btn', { hasText: 'Hendrik' }).click(); + await expect(page.getByRole('button', { name: 'Profil wechseln' })).toContainText('Hendrik'); + }); + + test('mit aktivem Profil: "Deine Favoriten"-Sektion erscheint', async ({ page }) => { + await setActiveProfile(page, HENDRIK_ID); + await page.goto('/'); + await expect( + page.getByRole('heading', { level: 2, name: 'Deine Favoriten' }) + ).toBeVisible(); + }); + + test('ohne Profil: Rating-Klick oeffnet Standard-Hinweis', async ({ page }) => { + await clearActiveProfile(page); + await page.goto('/recipes/66'); + await page.getByRole('button', { name: '5 Sterne' }).click(); + await expect(page.getByText('Kein Profil gewählt')).toBeVisible(); + await expect(page.getByText(/klappt die Aktion/)).toBeVisible(); + }); +}); diff --git a/tests/e2e/remote/recipe-detail.spec.ts b/tests/e2e/remote/recipe-detail.spec.ts new file mode 100644 index 0000000..f788104 --- /dev/null +++ b/tests/e2e/remote/recipe-detail.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; +import { setActiveProfile, HENDRIK_ID } from './fixtures/profile'; +import { + clearFavorite, + clearRating, + removeFromWishlist +} from './fixtures/api-cleanup'; + +// Chicken Teriyaki auf kochwas-dev: 4 Portionen, 500 g Haehnchen, 100 ml Soja. +const RECIPE_ID = 66; + +test.describe('Rezept-Detail', () => { + test.beforeEach(async ({ page }) => { + await setActiveProfile(page, HENDRIK_ID); + }); + + test.afterEach(async ({ request }) => { + await clearRating(request, RECIPE_ID, HENDRIK_ID); + await clearFavorite(request, RECIPE_ID, HENDRIK_ID); + await removeFromWishlist(request, RECIPE_ID, HENDRIK_ID); + }); + + test('Header + Zutaten sichtbar', async ({ page }) => { + await page.goto(`/recipes/${RECIPE_ID}`); + await expect( + page.getByRole('heading', { level: 1, name: /Chicken Teriyaki/i }) + ).toBeVisible(); + await expect(page.getByText('Hähnchenbrustfilet').first()).toBeVisible(); + }); + + test('Portionen-Scaler: 4 -> 6 skaliert Mengen proportional', async ({ page }) => { + await page.goto(`/recipes/${RECIPE_ID}`); + // Start: 4 Portionen, 500 g Haehnchen, 100 ml Soja. + await expect(page.locator('.srv-value strong').first()).toHaveText('4'); + await page.getByRole('button', { name: 'Mehr' }).first().click(); + await page.getByRole('button', { name: 'Mehr' }).first().click(); + await expect(page.locator('.srv-value strong').first()).toHaveText('6'); + // Skalierte Mengen 1.5x + await expect(page.getByText(/\b750 g/).first()).toBeVisible(); + await expect(page.getByText(/\b150 ml/).first()).toBeVisible(); + }); + + test('Favorit toggelt heart-Klasse sauber', async ({ page }) => { + await page.goto(`/recipes/${RECIPE_ID}`); + const favBtn = page.getByRole('button', { name: 'Favorit' }); + await expect(favBtn).not.toHaveClass(/heart/); + await favBtn.click(); + await expect(favBtn).toHaveClass(/heart/); + await favBtn.click(); + await expect(favBtn).not.toHaveClass(/heart/); + }); + + test('Rating persistiert ueber Reload', async ({ page }) => { + await page.goto(`/recipes/${RECIPE_ID}`); + await page.getByRole('button', { name: '4 Sterne' }).click(); + await expect(page.getByRole('button', { name: '4 Sterne' })).toHaveClass(/filled/); + await page.reload(); + await expect(page.getByRole('button', { name: '4 Sterne' })).toHaveClass(/filled/); + }); + + test('Heute gekocht inkrementiert Counter', async ({ page }) => { + await page.goto(`/recipes/${RECIPE_ID}`); + const cookedBtn = page.getByRole('button', { name: /Heute gekocht/i }); + const before = (await cookedBtn.innerText()).trim(); + await cookedBtn.click(); + // Der Button bekommt einen "(N)"-Suffix bzw. der existierende zaehler + // steigt. Wir pruefen nur, dass sich der Text aendert. + await expect(cookedBtn).not.toHaveText(before); + }); + + test('Auf Wunschliste-Toggle funktioniert', async ({ page }) => { + await page.goto(`/recipes/${RECIPE_ID}`); + const wishBtn = page.getByRole('button', { name: /Auf Wunschliste/i }); + const initialLabel = (await wishBtn.getAttribute('aria-label')) ?? ''; + await wishBtn.click(); + // aria-label wechselt zwischen "setzen" und "Von der Wunschliste entfernen" + await expect(wishBtn).not.toHaveAttribute('aria-label', initialLabel); + }); +}); diff --git a/tests/e2e/remote/search.spec.ts b/tests/e2e/remote/search.spec.ts new file mode 100644 index 0000000..ff37692 --- /dev/null +++ b/tests/e2e/remote/search.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Suche', () => { + test('lokaler Treffer erscheint live beim Tippen', async ({ page }) => { + await page.goto('/'); + await page.getByRole('searchbox', { name: 'Suchbegriff' }).fill('lasagne'); + await expect(page.getByRole('link', { name: /Pfannen Lasagne/i })).toBeVisible({ + timeout: 5000 + }); + }); + + test('Web-Fallback bei unbekanntem Begriff', async ({ page }) => { + // Direkt per URL — spart den Debounce-Timer. + await page.goto('/?q=pizza+margherita'); + await expect(page.getByText(/Keine lokalen Rezepte/i)).toBeVisible({ timeout: 15000 }); + // Mindestens ein Web-Treffer mit einer Domain-Labeling. + await expect(page.getByText(/chefkoch\.de|rezeptwelt\.de/i).first()).toBeVisible(); + }); + + test('echter Empty-State bei Nonsense-Begriff', async ({ page }) => { + await page.goto('/?q=xxyyzznotarecipexxxxxxxx'); + await expect( + page.getByText(/Schaue unter den Topfdeckeln|Noch nichts gefunden|keine Rezepte/i) + ).toBeVisible({ timeout: 15000 }); + }); + + test('Deep-Link ?q=lasagne stellt Query im Input wieder her', async ({ page }) => { + await page.goto('/?q=lasagne'); + const sb = page.getByRole('searchbox', { name: 'Suchbegriff' }); + await expect(sb).toHaveValue('lasagne'); + }); +}); diff --git a/tests/e2e/remote/wishlist.spec.ts b/tests/e2e/remote/wishlist.spec.ts new file mode 100644 index 0000000..4675ff6 --- /dev/null +++ b/tests/e2e/remote/wishlist.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test'; +import { clearActiveProfile, setActiveProfile, HENDRIK_ID } from './fixtures/profile'; + +test.describe('Wunschliste-Seite', () => { + test('laedt Header + Sort-Tabs', async ({ page }) => { + await setActiveProfile(page, HENDRIK_ID); + await page.goto('/wishlist'); + await expect(page.getByRole('heading', { level: 1, name: 'Wunschliste' })).toBeVisible(); + for (const label of ['Meist gewünscht', 'Neueste', 'Älteste']) { + await expect(page.getByRole('tab', { name: label })).toBeVisible(); + } + }); + + test('Badge im Header stimmt mit Anzahl Eintraegen ueberein', async ({ page, request }) => { + await setActiveProfile(page, HENDRIK_ID); + await page.goto('/wishlist'); + // Die API zaehlt die Wunschlisten-Rezepte — der Header-Badge sollte + // die gleiche Zahl zeigen. + const res = await request.get('/api/wishlist?sort=popular'); + const body = (await res.json()) as { entries: unknown[] }; + const expected = body.entries.length; + if (expected === 0) { + // Kein Badge bei Null — der Link hat dann gar keine Zahl. + return; + } + const badge = page.locator('a[href="/wishlist"]').first(); + await expect(badge).toContainText(String(expected)); + }); + + test('requireProfile zeigt Custom-Message "um mitzuwuenschen"', async ({ page }) => { + await clearActiveProfile(page); + await page.goto('/wishlist'); + // Erster "Ich will das auch"-Button eines beliebigen Eintrags. + // Falls Wunschliste leer ist, ueberspringen. + const btn = page.getByRole('button', { name: /Ich will das auch/i }).first(); + const count = await btn.count(); + test.skip(count === 0, 'Wunschliste leer — Custom-Message-Test uebersprungen'); + + await btn.click(); + await expect(page.getByText('Kein Profil gewählt')).toBeVisible(); + await expect(page.getByText('um mitzuwünschen')).toBeVisible(); + }); +});