Compare commits
10 Commits
review-fix
...
3021ccb6a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3021ccb6a9 | ||
|
|
a7ad159c69 | ||
|
|
7da37d0a3d | ||
|
|
e953ca7870 | ||
|
|
c1789f902e | ||
|
|
02b9cdbc68 | ||
|
|
5a291a53dd | ||
|
|
98a8022ddf | ||
|
|
5a1ffee3bb | ||
|
|
9ee8efa479 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,4 +7,5 @@ data/
|
||||
*.log
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright-report-remote/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
30
playwright.remote.config.ts
Normal file
30
playwright.remote.config.ts
Normal file
@@ -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'
|
||||
}
|
||||
});
|
||||
@@ -66,12 +66,14 @@ export const profileStore = new ProfileStore();
|
||||
* Returns the active profile, or null after showing the standard
|
||||
* "kein Profil gewählt" dialog. Use as the first line of any per-profile
|
||||
* action so we don't repeat the guard at every call-site.
|
||||
*
|
||||
* `message` ueberschreibt den Default, wenn eine Aktion einen spezifischen
|
||||
* Hinweis braucht (z. B. „um mitzuwünschen" auf der Wunschliste).
|
||||
*/
|
||||
export async function requireProfile(): Promise<Profile | null> {
|
||||
export async function requireProfile(
|
||||
message = 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
): Promise<Profile | null> {
|
||||
if (profileStore.active) return profileStore.active;
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
});
|
||||
await alertAction({ title: 'Kein Profil gewählt', message });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
border: 1px solid #cfd9d1;
|
||||
background: white;
|
||||
font-size: 0.95rem;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, ImagePlus, ImageOff } from 'lucide-svelte';
|
||||
import type { Recipe, Ingredient, Step } from '$lib/types';
|
||||
import { alertAction, confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
|
||||
type Props = {
|
||||
@@ -25,7 +27,7 @@
|
||||
|
||||
let { recipe, saving = false, onsave, oncancel, onimagechange }: Props = $props();
|
||||
|
||||
let imagePath = $state<string | null>(recipe.image_path);
|
||||
let imagePath = $state<string | null>(untrack(() => recipe.image_path));
|
||||
let uploading = $state(false);
|
||||
let fileInput: HTMLInputElement | null = $state(null);
|
||||
|
||||
@@ -47,18 +49,12 @@
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch(`/api/recipes/${recipe.id}/image`, {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
await alertAction({
|
||||
title: 'Upload fehlgeschlagen',
|
||||
message: body.message ?? `HTTP ${res.status}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
const res = await asyncFetch(
|
||||
`/api/recipes/${recipe.id}/image`,
|
||||
{ method: 'POST', body: fd },
|
||||
'Upload fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
const body = await res.json();
|
||||
imagePath = body.image_path;
|
||||
onimagechange?.(imagePath);
|
||||
@@ -79,14 +75,12 @@
|
||||
if (!requireOnline('Das Entfernen')) return;
|
||||
uploading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/recipes/${recipe.id}/image`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
await alertAction({
|
||||
title: 'Entfernen fehlgeschlagen',
|
||||
message: `HTTP ${res.status}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
const res = await asyncFetch(
|
||||
`/api/recipes/${recipe.id}/image`,
|
||||
{ method: 'DELETE' },
|
||||
'Entfernen fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
imagePath = null;
|
||||
onimagechange?.(null);
|
||||
} finally {
|
||||
@@ -94,12 +88,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
let title = $state(recipe.title);
|
||||
let description = $state(recipe.description ?? '');
|
||||
let servings = $state<number | ''>(recipe.servings_default ?? '');
|
||||
let prepMin = $state<number | ''>(recipe.prep_time_min ?? '');
|
||||
let cookMin = $state<number | ''>(recipe.cook_time_min ?? '');
|
||||
let totalMin = $state<number | ''>(recipe.total_time_min ?? '');
|
||||
// Form-lokaler Zustand: Initialwerte aus dem Prop snapshotten (untrack),
|
||||
// damit User-Edits nicht von prop-Updates ueberschrieben werden.
|
||||
let title = $state(untrack(() => recipe.title));
|
||||
let description = $state(untrack(() => recipe.description ?? ''));
|
||||
let servings = $state<number | ''>(untrack(() => recipe.servings_default ?? ''));
|
||||
let prepMin = $state<number | ''>(untrack(() => recipe.prep_time_min ?? ''));
|
||||
let cookMin = $state<number | ''>(untrack(() => recipe.cook_time_min ?? ''));
|
||||
let totalMin = $state<number | ''>(untrack(() => recipe.total_time_min ?? ''));
|
||||
|
||||
type DraftIng = {
|
||||
qty: string;
|
||||
@@ -110,15 +106,17 @@
|
||||
type DraftStep = { text: string };
|
||||
|
||||
let ingredients = $state<DraftIng[]>(
|
||||
recipe.ingredients.map((i) => ({
|
||||
qty: i.quantity !== null ? String(i.quantity).replace('.', ',') : '',
|
||||
unit: i.unit ?? '',
|
||||
name: i.name,
|
||||
note: i.note ?? ''
|
||||
}))
|
||||
untrack(() =>
|
||||
recipe.ingredients.map((i) => ({
|
||||
qty: i.quantity !== null ? String(i.quantity).replace('.', ',') : '',
|
||||
unit: i.unit ?? '',
|
||||
name: i.name,
|
||||
note: i.note ?? ''
|
||||
}))
|
||||
)
|
||||
);
|
||||
let steps = $state<DraftStep[]>(
|
||||
recipe.steps.map((s) => ({ text: s.text }))
|
||||
untrack(() => recipe.steps.map((s) => ({ text: s.text })))
|
||||
);
|
||||
|
||||
function addIngredient() {
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
.pill {
|
||||
padding: 0.15rem 0.55rem;
|
||||
background: #eaf4ed;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
font-size: 0.8rem;
|
||||
color: #2b6a3d;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: white;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
color: #555;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
padding: 0.6rem 0.85rem 0.6rem 1.1rem;
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
z-index: 500;
|
||||
max-width: calc(100% - 2rem);
|
||||
@@ -58,7 +58,7 @@
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
@@ -75,7 +75,7 @@
|
||||
padding: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dismiss:hover {
|
||||
|
||||
@@ -386,6 +386,9 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(:root) {
|
||||
--pill-radius: 999px;
|
||||
}
|
||||
:global(html, body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -429,7 +432,7 @@
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
color: #2b6a3d;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
@@ -621,7 +624,7 @@
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
text-decoration: none;
|
||||
font-size: 1.15rem;
|
||||
position: relative;
|
||||
@@ -636,7 +639,7 @@
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
background: #c53030;
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
|
||||
@@ -653,7 +653,7 @@
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: white;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
color: #2b6a3d;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
@@ -760,7 +760,7 @@
|
||||
right: 0.4rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
border: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #444;
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
padding: 0.5rem 0.95rem 0.5rem 0.8rem;
|
||||
background: white;
|
||||
border: 1px solid #e4eae7;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
text-decoration: none;
|
||||
color: #444;
|
||||
font-size: 0.95rem;
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: #eaf4ed;
|
||||
color: #2b6a3d;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.actions {
|
||||
|
||||
@@ -33,7 +33,12 @@
|
||||
$effect(() => {
|
||||
const u = ($page.url.searchParams.get('url') ?? '').trim();
|
||||
targetUrl = u;
|
||||
if (u) void load(u);
|
||||
if (u) {
|
||||
void load(u);
|
||||
} else {
|
||||
loading = false;
|
||||
errored = 'Kein ?url=-Parameter. Suche zuerst ein Rezept und klicke auf einen Treffer.';
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
|
||||
@@ -441,7 +441,7 @@
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
background: white;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { onMount, onDestroy, tick, untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
let editMode = $state(false);
|
||||
let saving = $state(false);
|
||||
let recipeState = $state(data.recipe);
|
||||
let recipeState = $state(untrack(() => data.recipe));
|
||||
|
||||
// Einmalige Pulse-Animation beim Aktivieren (nicht beim Wieder-Abwählen).
|
||||
// Per tick()-Zwischenschritt "aus → an" erzwingen, damit die Animation
|
||||
@@ -194,6 +194,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(id: number) {
|
||||
const ok = await confirmAction({
|
||||
title: 'Kommentar löschen?',
|
||||
message: 'Der Eintrag verschwindet ohne Umweg.',
|
||||
confirmLabel: 'Löschen',
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
if (!requireOnline('Das Löschen')) return;
|
||||
const res = await asyncFetch(
|
||||
`/api/recipes/${data.recipe.id}/comments`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ comment_id: id })
|
||||
},
|
||||
'Löschen fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
comments = comments.filter((c) => c.id !== id);
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
const ok = await confirmAction({
|
||||
title: 'Rezept löschen?',
|
||||
@@ -466,6 +488,16 @@
|
||||
<div class="author">{c.author}</div>
|
||||
<div class="text">{c.text}</div>
|
||||
<div class="date">{new Date(c.created_at).toLocaleString('de-DE')}</div>
|
||||
{#if profileStore.active?.id === c.profile_id}
|
||||
<button
|
||||
type="button"
|
||||
class="comment-del"
|
||||
aria-label="Kommentar löschen"
|
||||
onclick={() => void deleteComment(c.id)}
|
||||
>
|
||||
<Trash2 size="14" />
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -673,6 +705,26 @@
|
||||
border: 1px solid #e4eae7;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
position: relative;
|
||||
}
|
||||
.comment-del {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.comment-del:hover {
|
||||
background: #f3f5f3;
|
||||
color: #b42626;
|
||||
}
|
||||
.comments .author {
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Utensils, Trash2, CookingPot } from 'lucide-svelte';
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import { profileStore, requireProfile } from '$lib/client/profile.svelte';
|
||||
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
||||
import { alertAction, confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
|
||||
|
||||
@@ -35,15 +35,12 @@
|
||||
});
|
||||
|
||||
async function toggleMine(entry: WishlistEntry) {
|
||||
if (!profileStore.active) {
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", um mitzuwünschen.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profile = await requireProfile(
|
||||
'Tippe oben rechts auf „Profil wählen", um mitzuwünschen.'
|
||||
);
|
||||
if (!profile) return;
|
||||
if (!requireOnline('Die Wunschlisten-Aktion')) return;
|
||||
const profileId = profileStore.active.id;
|
||||
const profileId = profile.id;
|
||||
if (entry.on_my_wishlist) {
|
||||
await fetch(`/api/wishlist/${entry.recipe_id}?profile_id=${profileId}`, {
|
||||
method: 'DELETE'
|
||||
@@ -185,7 +182,7 @@
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: white;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
color: #2b6a3d;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
71
tests/e2e/remote/comments.spec.ts
Normal file
71
tests/e2e/remote/comments.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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.
|
||||
// Es gibt mehrere "Löschen"-Buttons auf der Seite (Rezept-Delete,
|
||||
// Kommentar-Trash, Dialog-Bestaetigung) — deshalb Locator auf den
|
||||
// Dialog einschraenken.
|
||||
const dialog = page.getByRole('dialog', { name: /Kommentar löschen/i });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.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();
|
||||
});
|
||||
});
|
||||
84
tests/e2e/remote/recipe-detail.spec.ts
Normal file
84
tests/e2e/remote/recipe-detail.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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 — ueber das Item-Name-Filter, robuster
|
||||
// gegenueber Whitespace-Quirks zwischen <span class="qty">-Teilen.
|
||||
await expect(
|
||||
page.locator('.ing-list li', { hasText: 'Hähnchenbrustfilet' })
|
||||
).toContainText('750 g');
|
||||
await expect(
|
||||
page.locator('.ing-list li', { hasText: 'Sojasauce' })
|
||||
).toContainText('150 ml');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
39
tests/e2e/remote/search.spec.ts
Normal file
39
tests/e2e/remote/search.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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('Nonsense-Query rendert Fallback ohne Crash', async ({ page }) => {
|
||||
// SearXNG matcht loose — selbst Nonsense gibt oft Fuzzy-Treffer.
|
||||
// Wir pruefen deshalb nur, dass die Seite sinnvoll reagiert
|
||||
// (entweder echter Empty-State ODER Web-Fallback) und kein JS-Fehler
|
||||
// fliegt.
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
await page.goto('/?q=xxyyzznotarecipexxxxxxxx');
|
||||
await expect(
|
||||
page.getByText(/Schaue unter den Topfdeckeln|Keine lokalen Rezepte/i)
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
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