test(e2e): Playwright Smoketests gegen kochwas-dev (remote)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m42s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m42s
Automatisierte End-to-End-Tests gegen ein deployed Environment. Loest die manuellen MCP-Playwright-Runs ab. 42 Tests in 9 Files: - homepage: H1, Sektionen, Sort-Tabs, Console-Errors - search: lokaler Treffer, Web-Fallback, Empty-State, Deep-Link - profile: Switcher, Auswahl-Persistenz, Favoriten-Section, Guard-Dialog - recipe-detail: Header, Portionen-Scaling (4->6), Favorit-Toggle, Rating-Persistenz ueber Reload, Gekocht-Counter, Wunschliste-Toggle - comments: eigenen erstellen+loeschen via UI, fremder hat kein Delete - wishlist: Seite, Sort-Tabs, Badge-Sync, requireProfile-Custom-Message - preview: Guard ohne ?url=, echte URL parst, unparsbare zeigt error-box - admin: alle 4 Subrouten + /admin redirect - api-errors: parsePositiveIntParam (4x Invalid id), validateBody (4x Invalid body + issues), 404, Sanity /health /profiles /domains Architektur: - Separate playwright.remote.config.ts (getrennt von local preview) - workers: 1 + afterEach API-Cleanup (rating, favorite, wishlist, comments) - Hardcoded Recipe-ID 66 + Profile 1/2/3 — stabile Dev-DB-Seeds - E2E_REMOTE_URL ueberschreibt die Ziel-URL Ausfuehrung: npm run test:e2e:remote Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
68
tests/e2e/remote/README.md
Normal file
68
tests/e2e/remote/README.md
Normal file
@@ -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.
|
||||
20
tests/e2e/remote/admin.spec.ts
Normal file
20
tests/e2e/remote/admin.spec.ts
Normal file
@@ -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$/);
|
||||
});
|
||||
});
|
||||
101
tests/e2e/remote/api-errors.spec.ts
Normal file
101
tests/e2e/remote/api-errors.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
67
tests/e2e/remote/comments.spec.ts
Normal file
67
tests/e2e/remote/comments.spec.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
67
tests/e2e/remote/fixtures/api-cleanup.ts
Normal file
67
tests/e2e/remote/fixtures/api-cleanup.ts
Normal file
@@ -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<void> {
|
||||
await api.delete(`/api/recipes/${recipeId}/rating`, {
|
||||
data: { profile_id: profileId }
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearFavorite(
|
||||
api: APIRequestContext,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): Promise<void> {
|
||||
await api.delete(`/api/recipes/${recipeId}/favorite`, {
|
||||
data: { profile_id: profileId }
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeFromWishlist(
|
||||
api: APIRequestContext,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): Promise<void> {
|
||||
await api.delete(`/api/wishlist/${recipeId}?profile_id=${profileId}`);
|
||||
}
|
||||
|
||||
export async function deleteComment(
|
||||
api: APIRequestContext,
|
||||
recipeId: number,
|
||||
commentId: number
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
tests/e2e/remote/fixtures/profile.ts
Normal file
26
tests/e2e/remote/fixtures/profile.ts
Normal file
@@ -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<void> {
|
||||
await page.addInitScript(
|
||||
(pid) => window.localStorage.setItem('kochwas.activeProfileId', String(pid)),
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearActiveProfile(page: Page): Promise<void> {
|
||||
await page.addInitScript(() =>
|
||||
window.localStorage.removeItem('kochwas.activeProfileId')
|
||||
);
|
||||
}
|
||||
43
tests/e2e/remote/homepage.spec.ts
Normal file
43
tests/e2e/remote/homepage.spec.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
29
tests/e2e/remote/preview.spec.ts
Normal file
29
tests/e2e/remote/preview.spec.ts
Normal file
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
40
tests/e2e/remote/profile.spec.ts
Normal file
40
tests/e2e/remote/profile.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
79
tests/e2e/remote/recipe-detail.spec.ts
Normal file
79
tests/e2e/remote/recipe-detail.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
32
tests/e2e/remote/search.spec.ts
Normal file
32
tests/e2e/remote/search.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
43
tests/e2e/remote/wishlist.spec.ts
Normal file
43
tests/e2e/remote/wishlist.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user