diff --git a/app/database.py b/app/database.py index a92eadd..0d47ef0 100755 --- a/app/database.py +++ b/app/database.py @@ -1,8 +1,9 @@ import os import re import sqlite3 -from datetime import datetime, date -from typing import List, Dict, Any, Optional +import secrets +from datetime import datetime, date, timedelta +from typing import List, Dict, Any, Optional, Tuple try: from dotenv import load_dotenv @@ -524,6 +525,21 @@ def init_db(): except Exception: pass + cursor.execute(""" + CREATE TABLE IF NOT EXISTS recipe_shares ( + id INT AUTO_INCREMENT PRIMARY KEY, + recipe_id INT NOT NULL, + token VARCHAR(64) NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NULL DEFAULT NULL, + views_count INT DEFAULT 0, + revoked TINYINT DEFAULT 0, + label VARCHAR(100) DEFAULT '', + INDEX idx_share_token (token), + INDEX idx_share_recipe (recipe_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """) + cursor.execute("DELETE FROM dishes") else: cursor = conn.cursor() @@ -631,6 +647,21 @@ def init_db(): if "note" not in existing_shop_cols: cursor.execute("ALTER TABLE shopping_list ADD COLUMN note TEXT DEFAULT ''") + cursor.execute(""" + CREATE TABLE IF NOT EXISTS recipe_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipe_id INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NULL, + views_count INTEGER DEFAULT 0, + revoked INTEGER DEFAULT 0, + label TEXT DEFAULT '' + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_share_token ON recipe_shares (token)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_share_recipe ON recipe_shares (recipe_id)") + cursor.execute("DELETE FROM dishes") conn.commit() finally: @@ -1633,3 +1664,140 @@ def get_meal_plan_ingredients(year: int, week: int) -> List[Dict[str, Any]]: + + +# --- Rezept-Freigaben (Share Links) --- + +def parse_db_datetime(dt_val: Any) -> Optional[datetime]: + if not dt_val: + return None + if isinstance(dt_val, datetime): + return dt_val + try: + return datetime.fromisoformat(str(dt_val)) + except Exception: + try: + return datetime.strptime(str(dt_val).split(".")[0], "%Y-%m-%d %H:%M:%S") + except Exception: + return None + + +def format_share_expiry(dt_val: Any) -> Tuple[str, str, bool]: + """ + Gibt (formatted_date, relative_status, is_expired) zurück. + """ + dt = parse_db_datetime(dt_val) + if not dt: + return ("Unbegrenzt", "Unbegrenzt gültig", False) + + now = datetime.now() + is_expired = now > dt + date_str = dt.strftime("%d.%m.%Y %H:%M") + + if is_expired: + return (date_str, f"Abgelaufen am {date_str} Uhr", True) + + delta = dt - now + if delta.days > 0: + days = delta.days + rel = f"Noch {days} {'Tag' if days == 1 else 'Tage'} gültig" + else: + hours = max(1, int(delta.total_seconds() // 3600)) + rel = f"Noch {hours} {'Stunde' if hours == 1 else 'Stunden'} gültig" + + return (date_str, rel, False) + + +def create_recipe_share(recipe_id: int, expires_in_seconds: Optional[int] = None, label: str = "") -> Dict[str, Any]: + token = secrets.token_urlsafe(16) + expires_at_dt = None + expires_at_sql = None + if expires_in_seconds is not None and expires_in_seconds > 0: + expires_at_dt = datetime.now() + timedelta(seconds=expires_in_seconds) + expires_at_sql = expires_at_dt.strftime("%Y-%m-%d %H:%M:%S") + + res = execute_query( + "INSERT INTO recipe_shares (recipe_id, token, expires_at, label) VALUES (?, ?, ?, ?)", + (recipe_id, token, expires_at_sql, label.strip()), + fetch="none", + commit=True + ) + last_id = res.get("lastrowid") if isinstance(res, dict) else None + + fmt_date, rel_status, is_exp = format_share_expiry(expires_at_dt) + return { + "id": last_id, + "recipe_id": recipe_id, + "token": token, + "created_at": datetime.now().isoformat(), + "expires_at": expires_at_dt.isoformat() if expires_at_dt else None, + "expires_at_formatted": fmt_date, + "relative_status": rel_status, + "is_expired": is_exp, + "views_count": 0, + "revoked": False, + "label": label.strip() + } + + +def get_recipe_share_by_token(token: str, record_view: bool = False) -> Optional[Dict[str, Any]]: + row = execute_query( + "SELECT * FROM recipe_shares WHERE token = ? LIMIT 1", + (token.strip(),), + fetch="one" + ) + if not row: + return None + + fmt_date, rel_status, is_exp = format_share_expiry(row.get("expires_at")) + is_revoked = bool(row.get("revoked", 0)) + + if record_view and not is_revoked and not is_exp: + execute_query("UPDATE recipe_shares SET views_count = views_count + 1 WHERE id = ?", (row["id"],), commit=True) + row["views_count"] = row.get("views_count", 0) + 1 + + row["is_expired"] = is_exp + row["revoked"] = is_revoked + row["expires_at_formatted"] = fmt_date + row["relative_status"] = rel_status + row["is_active"] = (not is_revoked and not is_exp) + return row + + +def get_recipe_shares_for_recipe(recipe_id: int) -> List[Dict[str, Any]]: + rows = execute_query( + "SELECT * FROM recipe_shares WHERE recipe_id = ? ORDER BY id DESC", + (recipe_id,), + fetch="all" + ) + results = [] + for r in (rows or []): + fmt_date, rel_status, is_exp = format_share_expiry(r.get("expires_at")) + is_revoked = bool(r.get("revoked", 0)) + r["is_expired"] = is_exp + r["revoked"] = is_revoked + r["expires_at_formatted"] = fmt_date + r["relative_status"] = rel_status + r["is_active"] = (not is_revoked and not is_exp) + results.append(r) + return results + + +def delete_recipe_share(share_id: int, recipe_id: int) -> bool: + res = execute_query( + "DELETE FROM recipe_shares WHERE id = ? AND recipe_id = ?", + (share_id, recipe_id), + fetch="none", + commit=True + ) + return bool(res and res.get("rowcount", 0) > 0) + + +def revoke_recipe_share(share_id: int, recipe_id: int) -> bool: + res = execute_query( + "UPDATE recipe_shares SET revoked = 1 WHERE id = ? AND recipe_id = ?", + (share_id, recipe_id), + fetch="none", + commit=True + ) + return bool(res and res.get("rowcount", 0) > 0) diff --git a/app/main.py b/app/main.py index 22afaa1..09c60e6 100755 --- a/app/main.py +++ b/app/main.py @@ -60,7 +60,12 @@ from .database import ( reset_default_shopping_categories, update_shopping_item_category, update_shopping_item, - SHELF_TYPES + SHELF_TYPES, + create_recipe_share, + get_recipe_share_by_token, + get_recipe_shares_for_recipe, + delete_recipe_share, + revoke_recipe_share ) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -110,7 +115,7 @@ app = FastAPI(title="What To Cook? - Zufallsgenerator & Kochbuch", lifespan=life @app.middleware("http") async def auth_middleware(request: Request, call_next): path = request.url.path - if path.startswith("/static/") or path in PUBLIC_PATHS: + if path.startswith("/static/") or path.startswith("/shared/") or path in PUBLIC_PATHS: return await call_next(request) token = request.cookies.get(COOKIE_NAME) @@ -512,6 +517,44 @@ async def recipe_detail_view(request: Request, recipe_id: int): } ) + +@app.get("/shared/recipe/{token}", response_class=HTMLResponse) +async def shared_recipe_view(request: Request, token: str): + """Öffentlich freigegebene Rezeptseite (mit Gültigkeitsprüfung)""" + share = get_recipe_share_by_token(token, record_view=True) + if not share or share.get("is_expired") or share.get("revoked"): + return templates.TemplateResponse( + request=request, + name="shared_expired.html", + context={ + "share": share, + "reason": "revoked" if (share and share.get("revoked")) else ("expired" if (share and share.get("is_expired")) else "not_found") + }, + status_code=410 if (share and (share.get("is_expired") or share.get("revoked"))) else 404 + ) + + recipe = get_recipe(share["recipe_id"]) + if not recipe: + raise HTTPException(status_code=404, detail="Rezept nicht gefunden.") + + is_matrix = bool(recipe.get("has_portion_table")) or any(k in (recipe.get("title") or "").lower() for k in ["waffel", "pancake", "pfannkuchen"]) + recipe["has_portion_table"] = is_matrix + if is_matrix: + parsed = parse_ingredients_block(recipe.get("ingredients", "")) + recipe["portion_matrix"] = calculate_portion_matrix(parsed, base_portions=recipe.get("portions") or 4, targets=(2, 4, 6, 8)) + + return templates.TemplateResponse( + request=request, + name="recipe_detail.html", + context={ + "recipe": recipe, + "chapters": CHAPTERS, + "is_shared": True, + "share": share + } + ) + + @app.get("/cookbook/new", response_class=HTMLResponse) async def recipe_new_view(request: Request, chapter: Optional[int] = Query(None)): """Eingabemaske für neues Rezept""" @@ -720,6 +763,70 @@ def api_toggle_recipe_printed(recipe_id: int): raise HTTPException(status_code=404, detail=str(e)) +# --- Rezept-Share API --- + +class CreateShareRequest(BaseModel): + expires_in: Optional[str] = "7d" + label: Optional[str] = "" + +def parse_expiry_to_seconds(val: Optional[str]) -> Optional[int]: + if not val or val == "unlimited" or val == "0" or val == "never": + return None + val = val.lower().strip() + if val.endswith("h"): + return int(float(val[:-1]) * 3600) + elif val.endswith("d"): + return int(float(val[:-1]) * 86400) + elif val.endswith("w"): + return int(float(val[:-1]) * 7 * 86400) + elif val.endswith("m"): + return int(float(val[:-1]) * 30 * 86400) + try: + sec = int(val) + return sec if sec > 0 else None + except ValueError: + return 7 * 86400 + +def get_base_url_for_request(request: Request) -> str: + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc)) + return f"{scheme}://{host}".rstrip("/") + +@app.get("/api/recipes/{recipe_id}/shares") +def api_get_recipe_shares(request: Request, recipe_id: int): + recipe = get_recipe(recipe_id) + if not recipe: + raise HTTPException(status_code=404, detail="Rezept nicht gefunden.") + shares = get_recipe_shares_for_recipe(recipe_id) + base_url = get_base_url_for_request(request) + for s in shares: + s["share_url"] = f"{base_url}/shared/recipe/{s['token']}" + s["share_path"] = f"/shared/recipe/{s['token']}" + if isinstance(s.get("created_at"), (datetime, date)): + s["created_at"] = s["created_at"].strftime("%d.%m.%Y %H:%M") + return {"status": "ok", "shares": shares} + +@app.post("/api/recipes/{recipe_id}/shares") +def api_create_recipe_share(request: Request, recipe_id: int, payload: CreateShareRequest): + recipe = get_recipe(recipe_id) + if not recipe: + raise HTTPException(status_code=404, detail="Rezept nicht gefunden.") + sec = parse_expiry_to_seconds(payload.expires_in) + share = create_recipe_share(recipe_id, expires_in_seconds=sec, label=payload.label or "") + base_url = get_base_url_for_request(request) + share["share_url"] = f"{base_url}/shared/recipe/{share['token']}" + share["share_path"] = f"/shared/recipe/{share['token']}" + return {"status": "ok", "share": share} + +@app.delete("/api/recipes/{recipe_id}/shares/{share_id}") +def api_delete_recipe_share(recipe_id: int, share_id: int): + success = delete_recipe_share(share_id, recipe_id) + if not success: + raise HTTPException(status_code=404, detail="Share-Link nicht gefunden oder bereits gelöscht.") + return {"status": "ok", "message": "Share-Link wurde erfolgreich gelöscht."} + + + # ========================================== # API: ZUFALLSGENERATOR & GERICHTE # ========================================== diff --git a/app/static/cookbook.css b/app/static/cookbook.css index 5c5cd5e..e0343be 100755 --- a/app/static/cookbook.css +++ b/app/static/cookbook.css @@ -1,2692 +1,2991 @@ -/* ========================================================================== - Mein Kochbuch - Digital & A4 Print Layout Theme - Farben & Typografie inspiriert vom Design-Prototyp - ========================================================================== */ - -@import url('https://fonts.googleapis.com/css2?family=Architects+Daughter&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap'); - -:root { - --creme: #FAF6F0; - --tannengruen: #1E4D3B; - --tannengruen-light: rgba(30, 77, 59, 0.12); - --altrosa: #D29B9B; - --altrosa-light: rgba(210, 155, 155, 0.2); - --schwarz: #222222; - --grau-bg: #e6e2dc; - --border-hand: 2px solid var(--schwarz); - --font-hand: 'Architects Daughter', cursive, sans-serif; - --font-sans: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --radius-recipe: 14px; -} - -/* Base Body for Cookbook view */ -.cookbook-body { - margin: 0; - padding: 0; - background-color: var(--grau-bg); - font-family: var(--font-sans); - color: var(--schwarz); - min-height: 100vh; - display: flex; - flex-direction: column; -} - -@media screen { - html { - overflow-x: clip; - } - .cookbook-body { - overflow-x: clip; - width: 100%; - } -} - -/* Cookbook Navigation Bar (Fest / Sticky beim Scrollen) */ -.cb-nav { - position: sticky; - top: 0; - z-index: 1000; - background: var(--tannengruen); - color: var(--creme); - padding: 6px 14px; - min-height: 48px; - box-shadow: 0 4px 15px rgba(0,0,0,0.25); - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - width: 100%; - box-sizing: border-box; -} - -.cb-nav-brand { - display: flex; - align-items: center; - gap: 8px; - text-decoration: none; - color: var(--creme); - flex-shrink: 0; -} - -.cb-brand-icon { - font-size: 1.5rem; - line-height: 1; -} - -.cb-nav-title { - font-family: var(--font-hand); - font-size: 1.35rem; - letter-spacing: 0.02em; - margin: 0; - white-space: nowrap; -} - -/* Recipe Search Bar */ -.cb-search-box { - display: flex; - align-items: center; - position: relative; - flex: 0 1 240px; - max-width: 280px; - min-width: 130px; - background: rgba(250, 246, 240, 0.18); - border: 1.5px solid rgba(250, 246, 240, 0.45); - border-radius: 20px; - padding: 3px 10px; - transition: all 0.2s ease; - margin: 0 6px; -} - -.cb-search-box:focus-within { - background: var(--creme); - border-color: var(--creme); - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35); -} - -.cb-search-icon { - font-size: 0.95rem; - margin-right: 6px; - opacity: 0.85; - user-select: none; -} - -.cb-search-box:focus-within .cb-search-icon { - filter: brightness(0.2); -} - -.cb-search-input { - background: transparent; - border: none; - outline: none; - color: var(--creme); - font-family: var(--font-hand); - font-size: 1rem; - width: 100%; -} - -.cb-search-box:focus-within .cb-search-input { - color: var(--schwarz); -} - -.cb-search-input::placeholder { - color: rgba(250, 246, 240, 0.7); -} - -.cb-search-box:focus-within .cb-search-input::placeholder { - color: rgba(34, 34, 34, 0.55); -} - -.cb-search-clear { - background: none; - border: none; - color: inherit; - cursor: pointer; - padding: 0 4px; - font-size: 0.85rem; - opacity: 0.75; - line-height: 1; -} - -.cb-search-clear:hover { - opacity: 1; -} - -.recipe-page.search-hidden, -.divider.search-hidden { - display: none !important; -} - -#search-empty-state { - text-align: center; - padding: 40px 20px; - background: rgba(255, 255, 255, 0.7); - border: 2px dashed rgba(34, 34, 34, 0.25); - border-radius: 12px; - margin: 20px auto; - max-width: 500px; -} - -.cb-nav-actions { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -} - -.cb-btn { - font-family: var(--font-hand); - font-size: 1rem; - font-weight: 700; - padding: 8px 14px; - border-radius: 8px; - border: 2px solid transparent; - cursor: pointer; - text-decoration: none; - display: inline-flex; - align-items: center; - gap: 6px; - transition: all 0.2s ease; -} - -/* 100% einheitliche Navigationsleisten-Buttons */ -.cb-nav-btn { - background: var(--creme); - color: var(--tannengruen); - border: 2px solid var(--creme); - border-radius: 8px; - font-family: var(--font-hand); - font-weight: 700; - font-size: 0.98rem; - padding: 6px 14px; - cursor: pointer; - text-decoration: none; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - transition: all 0.18s ease; - box-shadow: 2px 2px 0px rgba(0, 0, 0, 0.15); - white-space: nowrap; - line-height: 1.2; -} - -.cb-nav-btn:hover { - background: #ffffff; - border-color: #ffffff; - color: var(--tannengruen); - transform: translateY(-1px); - box-shadow: 2px 3px 0px rgba(0, 0, 0, 0.25); -} - -.cb-nav-btn:active { - transform: translateY(0); - box-shadow: 1px 1px 0px rgba(0, 0, 0, 0.15); -} - -.cb-btn-creme { - background: var(--creme); - color: var(--tannengruen); - border-color: var(--creme); -} - -.cb-btn-creme:hover { - background: #fff; - transform: translateY(-1px); -} - -.cb-btn-outline { - background: transparent; - color: var(--creme); - border-color: var(--creme); -} - -.cb-btn-outline:hover { - background: rgba(250, 246, 240, 0.18); - border-color: #ffffff; - color: #ffffff; -} - -.cb-btn-green { - background: #143729; - color: var(--creme) !important; - border-color: var(--creme); - box-shadow: none; -} - -.cb-btn-green:hover { - background: #0f2c1f; - color: #ffffff !important; - border-color: #ffffff; - transform: translateY(-1px); -} - -/* Filter Bar: Schlank & horizontal scrollbar (links-rechts) */ -.cb-filter-bar { - position: sticky; - top: 48px; - z-index: 990; - background: var(--creme); - border-bottom: 2px solid rgba(34, 34, 34, 0.15); - padding: 6px 12px; - display: flex; - align-items: center; - overflow-x: auto; - white-space: nowrap; - scrollbar-width: thin; - -webkit-overflow-scrolling: touch; - width: 100%; - box-sizing: border-box; - box-shadow: 0 2px 6px rgba(0,0,0,0.06); -} - -.cb-filter-scroll-container { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: nowrap; - width: max-content; - padding: 2px 4px; -} - -.cb-chip { - flex-shrink: 0; - white-space: nowrap; - font-family: var(--font-hand); - font-size: 0.9rem; - padding: 5px 13px; - border-radius: 20px; - background: var(--grau-bg); - border: 1.5px solid rgba(34, 34, 34, 0.2); - color: var(--schwarz); - cursor: pointer; - text-decoration: none; - transition: all 0.2s; - display: inline-flex; - align-items: center; -} - -.cb-chip.active, .cb-chip:hover { - background: var(--tannengruen); - color: var(--creme); - border-color: var(--tannengruen); -} - -/* Page Container (Virtual Book / Scroll view) */ -.book-wrapper { - max-width: 960px; - width: 100%; - margin: 24px auto; - padding: 0 16px; - display: flex; - flex-direction: column; - gap: 36px; - box-sizing: border-box; -} - -/* --- A4 Page Container --- */ -.page { - background-color: var(--creme); - box-shadow: 0 6px 20px rgba(0,0,0,0.18); - position: relative; - overflow: hidden; - box-sizing: border-box; - border-radius: 6px; - min-height: 580px; -} - -/* (Druckstile zentral in cookbook.html + + + +
+
+ 📖 +

Mein Kochbuch

+
+
+ +
+
+
+ {% if reason == 'revoked' %}🔒{% else %}⏳{% endif %} +
+ +

+ {% if reason == 'revoked' %} + Freigabe wurde deaktiviert + {% elif reason == 'expired' %} + Rezept-Link ist abgelaufen + {% else %} + Link nicht gefunden + {% endif %} +

+ +

+ {% if reason == 'revoked' %} + Dieser Share-Link wurde vom Ersteller des Rezepts vorzeitig zurückgezogen. + {% elif reason == 'expired' %} + Die zeitliche Gültigkeit für diesen geteilten Rezept-Link ist leider abgelaufen. Bitte frage die Person, die das Rezept geteilt hat, nach einem neuen Link. + {% else %} + Der aufgerufene Share-Link ist ungültig oder existiert nicht mehr. + {% endif %} +

+ + {% if share and share.expires_at_formatted and reason == 'expired' %} +
+ Ablaufzeitpunkt: {{ share.expires_at_formatted }} Uhr +
+ {% endif %} + + +
+
+ + + diff --git a/scripts/test_recipe_sharing.py b/scripts/test_recipe_sharing.py new file mode 100644 index 0000000..abac298 --- /dev/null +++ b/scripts/test_recipe_sharing.py @@ -0,0 +1,120 @@ +import os +import sys + +# Ensure project root is in sys.path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from fastapi.testclient import TestClient +from app.main import app, create_auth_token, COOKIE_NAME +from app.database import ( + create_recipe_share, + get_recipe_share_by_token, + get_recipe_shares_for_recipe, + delete_recipe_share, + get_recipe +) + +def run_tests(): + client = TestClient(app) + recipe = get_recipe(1) + assert recipe is not None, "Recipe ID 1 must exist for testing" + recipe_id = recipe["id"] + + print("--- 1. Testing Database Share Creation & Expiry Logic ---") + # Share for 24 hours + s24 = create_recipe_share(recipe_id, expires_in_seconds=86400, label="WhatsApp Familie") + assert s24["token"], "Must have generated token" + assert s24["is_expired"] is False, "Should not be expired" + assert "Noch 23" in s24["relative_status"] or "Noch 24" in s24["relative_status"] or "Noch 1 Tag" in s24["relative_status"] + print(f"[OK] 24h share created: {s24['token']} -> {s24['relative_status']}") + + # Unlimited share + sunlim = create_recipe_share(recipe_id, expires_in_seconds=None, label="Dauerhaft") + assert sunlim["expires_at"] is None, "Unlimited share should have None expires_at" + assert sunlim["relative_status"] == "Unbegrenzt gültig" + print(f"[OK] Unlimited share created: {sunlim['token']} -> {sunlim['relative_status']}") + + print("\n--- 2. Testing Public Access to Shared Recipe (No Auth Cookie) ---") + resp = client.get(f"/shared/recipe/{s24['token']}") + assert resp.status_code == 200, f"Expected 200 OK for public share link, got {resp.status_code}" + assert recipe["title"] in resp.text, "Shared page must contain recipe title" + assert "recipe-shared-banner" in resp.text, "Must render shared guest banner" + assert "Gastansicht" in resp.text, "Must have guest badge" + assert "Kochmodus" in resp.text, "Guest must have access to Kochmodus" + # Ensure private admin buttons are hidden in guest view + assert "Alle Rezepte" not in resp.text or "cb-nav-actions" not in resp.text or "/logout" not in resp.text + print("[OK] Public access without auth verified: Recipe renders in guest view with Kochmodus!") + + # Check view count incremented + fetched = get_recipe_share_by_token(s24["token"]) + assert fetched["views_count"] >= 1, f"Views count should be >= 1, got {fetched['views_count']}" + print(f"[OK] View count incremented: {fetched['views_count']} views recorded") + + print("\n--- 3. Testing Non-Existent & Expired Links ---") + resp_invalid = client.get("/shared/recipe/does_not_exist_xyz123") + assert resp_invalid.status_code == 404, f"Expected 404, got {resp_invalid.status_code}" + assert "Link nicht mehr" in resp_invalid.text or "ungültig" in resp_invalid.text + print("[OK] Invalid token handled with friendly 404 page") + + # Expired token test (past expiry) + from datetime import datetime, timedelta + from app.database import execute_query + expired_token = "test_expired_token_123" + past_date = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S") + execute_query("DELETE FROM recipe_shares WHERE token = ?", (expired_token,), fetch="none", commit=True) + execute_query("INSERT INTO recipe_shares (recipe_id, token, expires_at, label) VALUES (?, ?, ?, ?)", (recipe_id, expired_token, past_date, "Old"), fetch="none", commit=True) + + resp_exp = client.get(f"/shared/recipe/{expired_token}") + assert resp_exp.status_code == 410, f"Expected 410 Gone for expired link, got {resp_exp.status_code}" + assert "abgelaufen" in resp_exp.text.lower() + print("[OK] Expired link correctly returns 410 Gone with expiration notice") + + print("\n--- 4. Testing Authenticated Share Management APIs ---") + # Anonymous request to API must fail + resp_unauth = client.get(f"/api/recipes/{recipe_id}/shares") + assert resp_unauth.status_code == 401, f"Expected 401 Unauthorized for API without cookie, got {resp_unauth.status_code}" + print("[OK] Unauthenticated API request correctly rejected with 401") + + # Authenticated request + auth_token = create_auth_token("admin", days=1) + client.cookies.set(COOKIE_NAME, auth_token) + + # List shares + resp_list = client.get(f"/api/recipes/{recipe_id}/shares") + assert resp_list.status_code == 200, f"Expected 200, got {resp_list.status_code}" + shares = resp_list.json()["shares"] + assert any(s["token"] == s24["token"] for s in shares) + print(f"[OK] Authenticated GET /api/recipes/{recipe_id}/shares returned {len(shares)} shares") + + # Create new share via API + resp_create = client.post( + f"/api/recipes/{recipe_id}/shares", + json={"expires_in": "3d", "label": "Kollege"} + ) + assert resp_create.status_code == 200, f"Expected 200, got {resp_create.status_code}" + created_share = resp_create.json()["share"] + assert created_share["token"] + assert "share_url" in created_share + assert "Noch 2" in created_share["relative_status"] or "Noch 3" in created_share["relative_status"] + print(f"[OK] POST /api/recipes/{recipe_id}/shares created: {created_share['share_url']}") + + # Delete share via API + resp_del = client.delete(f"/api/recipes/{recipe_id}/shares/{created_share['id']}") + assert resp_del.status_code == 200, f"Expected 200, got {resp_del.status_code}" + # Verify it is no longer accessible + resp_after_del = client.get(f"/shared/recipe/{created_share['token']}") + assert resp_after_del.status_code in (404, 410) + print("[OK] DELETE /api/recipes/{recipe_id}/shares/{share_id} deleted and revoked access") + + # Clean up test shares + delete_recipe_share(s24["id"], recipe_id) + delete_recipe_share(sunlim["id"], recipe_id) + execute_query("DELETE FROM recipe_shares WHERE token = ?", (expired_token,), fetch="none", commit=True) + print("[OK] Test data cleanup complete") + + print("\n==========================================") + print("ALL RECIPE SHARING TESTS PASSED SUCCESSFULLY!") + print("==========================================") + +if __name__ == "__main__": + run_tests()