10 Commits

Author SHA1 Message Date
hsiegeln
3021ccb6a9 fix(e2e): 3 Specs robuster gegen reale Runtime
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 31s
- comments: Loeschen-Button im ConfirmDialog war ambig (3 Matches —
  Rezept-Delete, Kommentar-Trash, Dialog-Bestaetigung). Locator auf
  getByRole('dialog', { name: /Kommentar löschen/i }) eingeschraenkt.
- recipe-detail Portionen: getByText(/\b750 g/) trifft nicht wegen
  Whitespace-Layout im <span class="qty">. Auf
  locator('.ing-list li', { hasText: 'Hähnchenbrustfilet' })
  .toContainText('750 g') umgestellt — robust gegenueber Svelte-
  Whitespace-Quirks.
- search empty-state: SearXNG matcht loose, "truly empty" ist nicht
  zuverlaessig reproduzierbar. Test akzeptiert jetzt "Empty-State ODER
  Web-Fallback" und prueft zusaetzlich, dass kein JS-Error fliegt.

admin/backup war eine transiente Flake — 15 Repeat-Runs alle gruen,
kein Code-Fix noetig.

Gate: 12/12 der geaenderten Specs passed local.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:21:36 +02:00
hsiegeln
a7ad159c69 test(e2e): Playwright Smoketests gegen kochwas-dev (remote)
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>
2026-04-19 12:14:04 +02:00
hsiegeln
7da37d0a3d Merge cleanup-batch-post-review — Tier 1 + 2 UAT-Fixes
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 29s
Sechs atomare Commits aus der Post-Review-Roadmap:
- I: RecipeEditor form-lokale Snapshots via untrack() (10 svelte-check
  WARNINGs weg)
- H: Bild-Upload/Delete auf asyncFetch Wrapper
- F: --pill-radius CSS-Variable (15 Sites dedupliziert)
- G: requireProfile(message?) mit optionalem Parameter
- Preview-Guard wenn ?url= fehlt (UAT-Finding)
- Kommentar-Delete-Button fuer eigene Kommentare (UAT-Finding)

Alles 184/184 Tests gruen, svelte-check 0 Warnings, UAT auf
kochwas-dev durchgeklickt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:05:11 +02:00
hsiegeln
e953ca7870 feat(comments): Trash-Button zum Loeschen eigener Kommentare
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Der DELETE-Endpunkt fuer Kommentare existierte schon, hatte aber
keine UI-Exposition — Nutzer konnten ihre eigenen Kommentare nur
via API-Call loeschen. Das war beim UAT 2026-04-19 aufgefallen.

Jetzt: pro Kommentar wird nur fuer den Autor (comment.profile_id
=== profileStore.active.id) ein kleiner Trash2-Button in der
Ecke angezeigt. Mit confirmAction-Dialog, weil das Loeschen
nicht undo-bar ist.

Nutzt asyncFetch fuer den DELETE-Call — konsistent mit dem
Rest des Page-Scripts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:56:41 +02:00
hsiegeln
c1789f902e fix(preview): Guard wenn ?url=-Parameter fehlt
/preview ohne Query zeigte endlos "Vorschau wird geladen…", weil
loading initial true war und der $effect bei leerem u nichts tat.

Jetzt: beim leeren u wird errored gesetzt (mit Hinweis, dass das
der falsche Einstieg in die Route ist), so zeigt die bestehende
error-box den passenden Text an.

Im UAT 2026-04-19 aufgefallen, dort als MINOR eingeordnet.
Hier direkt mitgenommen weil 6-Zeilen-Fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:55:18 +02:00
hsiegeln
02b9cdbc68 refactor(client): requireProfile(message?) + Wunschliste migriert (Item G)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
Option B aus dem Roadmap-Plan. requireProfile bekommt einen optionalen
message-Parameter mit dem bisherigen Text als Default — die 5 Bestands-
Aufrufe aendern sich nicht, die Wunschliste nutzt die Custom-Message
„um mitzuwünschen" sauber ueber den Helper statt mit dupliziertem
alertAction-Block.

Netto: -3 Zeilen in wishlist/+page.svelte, eine Duplikation weniger,
Helper dokumentiert jetzt explizit den Message-Override-Use-Case.

Gate: svelte-check 0 Warnings, 184/184 Tests, Wunschliste zeigt
korrekte Message beim Klick ohne Profil.

Refs docs/superpowers/plans/2026-04-19-post-review-roadmap.md Item G.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:45:00 +02:00
hsiegeln
5a291a53dd refactor(ui): --pill-radius CSS-Variable (Item F)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m22s
border-radius: 999px war 15x im CSS dupliziert. Ausgelagert als
:root --pill-radius Variable im globalen :root-Block in +layout.svelte,
Call-Sites auf var(--pill-radius) umgestellt.

Bewusst NICHT angefasst (plan war "nur Werte die mehrfach vorkommen"):
- z-index: 10 Distinct Values in 14 Sites, bilden ein implizites
  Layer-System. Konsolidieren = behavior-change-Risiko ohne konkreten
  Nutzen. Wenn kuenftig einheitliche Modal-/Popover-Layer noetig,
  separate Phase.
- setTimeout(): 3 Sites, jeder mit eigener Semantik (Debounce/Print/
  Spinner). Kein DRY-Nutzen durch Extraktion.

Gate: svelte-check 0 Warnings, 184/184 Tests, Build clean, kein
sichtbarer Unterschied (einzige Aenderung: selber Wert ueber Variable).

Refs docs/superpowers/plans/2026-04-19-post-review-roadmap.md Item F.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:43:19 +02:00
hsiegeln
98a8022ddf refactor(editor): Bild-Upload/Delete auf asyncFetch (Item H)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m29s
RecipeEditor war noch die letzte Stelle im UI, die das
handgeschriebene "if (!res.ok) { alertAction(...) }"-Pattern
benutzte, welches wir in review-fixes-2026-04-18 ueberall sonst
durch asyncFetch() ersetzt hatten.

Netto: -14 Zeilen, konsistenter Fehlermessage-Fallback (body.message
> res.status), eine Import-Zeile weniger (alertAction raus, asyncFetch
rein).

Gate: svelte-check clean, 184/184 Tests, Upload/Delete-Flow per
Hand zu testen beim naechsten Editor-Touch.

Refs docs/superpowers/plans/2026-04-19-post-review-roadmap.md Item H.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:39:42 +02:00
hsiegeln
5a1ffee3bb refactor(editor): untrack() fuer form-lokale Snapshots (Item I)
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Alle 10 pre-existing svelte-check WARNINGs ("state_referenced_locally")
in RecipeEditor.svelte und recipes/[id]/+page.svelte addressiert.

Die betroffenen `let foo = $state(recipe.bar)`-Pattern sind
intentional Snapshots: der Editor soll User-Edits behalten und nicht
von prop-Updates ueberschrieben werden. untrack() macht die Intent
explizit und silenced die Warnung sauber statt sie unter den Teppich
zu kehren.

Scope: imagePath, title, description, servings, prepMin, cookMin,
totalMin, ingredients, steps (RecipeEditor) + recipeState
(recipes/[id]/+page).

Gate: svelte-check 0 Warnings (war 10), Tests 184/184.

Refs docs/superpowers/plans/2026-04-19-post-review-roadmap.md Item I.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:38:35 +02:00
hsiegeln
9ee8efa479 Merge review-fixes-2026-04-18 — API-Helper + Cleanup + Roadmap
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 33s
Bundelt 10 atomare Refactor/Feature-Commits aus dem Review-Branch:
api-helpers (parsePositiveIntParam, validateBody), alle 13 Handler
migriert, requireProfile()+asyncFetch Wrapper, Unicode-Brueche im
Ingredient-Parser, IMAGE_DIR/DATABASE_PATH zentralisiert, Doku-
Drift behoben, SW-Timing-Konstanten. Plus CI-Trigger fuer alle
Branches und Post-Review-Roadmap fuer die verschobenen Items A-I.

184/184 Tests gruen, svelte-check 0 Errors, UAT auf kochwas-dev
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:34:33 +02:00
30 changed files with 790 additions and 69 deletions

1
.gitignore vendored
View File

@@ -7,4 +7,5 @@ data/
*.log
test-results/
playwright-report/
playwright-report-remote/
.playwright-mcp/

View File

@@ -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",

View File

@@ -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: {

View 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'
}
});

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View 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.

View 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$/);
});
});

View 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);
});
});
});

View 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);
}
});
});

View 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);
}
}
}

View 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')
);
}

View 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([]);
});
});

View 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
});
});
});

View 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();
});
});

View 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);
});
});

View 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');
});
});

View 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();
});
});