diff --git a/backend/app/config.py b/backend/app/config.py
index 8c36368..16f6baa 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -39,6 +39,9 @@ class Settings(BaseSettings):
# Kategorie etwas sagen. Im Betrieb entscheidet die Einstellung
# "open_days"; dieser Wert gilt nur, solange dort nichts gesetzt ist.
open_days_default: int = 3
+ # Mealie: Adresse und Token stehen in der Datenbank (Admin traegt sie in der
+ # Oberflaeche ein), hier nur die Geduld fuer eine Antwort.
+ mealie_timeout_seconds: float = 10.0
# CORS (comma separated); "*" allows all
cors_origins: str = "*"
diff --git a/backend/app/main.py b/backend/app/main.py
index 8ec72b6..c54a7de 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -19,6 +19,7 @@ from .routers import (
loans,
locations,
maintenance,
+ mealie,
measures,
oidc,
package_types,
@@ -325,6 +326,7 @@ app.include_router(branding.router)
app.include_router(categories.router)
app.include_router(maintenance.router)
app.include_router(measures.router)
+app.include_router(mealie.router)
app.include_router(dashboard.router)
app.include_router(shops.router)
app.include_router(field_definitions.router)
diff --git a/backend/app/models.py b/backend/app/models.py
index 12c9cf7..39aa91a 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -790,6 +790,31 @@ class Setting(Base):
value: Mapped[str] = mapped_column(String(255), nullable=False)
+class IntegrationSecret(Base):
+ """Zugangsgeheimnis für einen externen Dienst (heute: das Mealie-Dauertoken).
+
+ Bewusst **nicht** in :class:`Setting`: ``GET /settings`` liefert jedem
+ angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort wäre
+ beim nächsten Geheimnis vergessen. Diese Tabelle hat deshalb überhaupt
+ keinen Leseweg nach außen — setzen, löschen, und eine Auskunft „ist
+ gesetzt". Was keinen Ausgang hat, kann nicht durchrutschen.
+
+ Anders als bei :class:`ApiToken` muss der Klartext erhalten bleiben: Wir
+ legen ihn einem fremden Dienst vor, ein Hash nützte dort nichts. Er liegt
+ damit unverschlüsselt in der Datenbank — eine Verschlüsselung wäre Theater,
+ solange der Schlüssel daneben läge. Der Schutz ist: kein Leseweg, nur Admin
+ beim Setzen, nie in einer Antwort.
+ """
+
+ __tablename__ = "integration_secrets"
+
+ service: Mapped[str] = mapped_column(String(32), primary_key=True)
+ secret: Mapped[str] = mapped_column(Text, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=_now, onupdate=_now
+ )
+
+
class Shop(Base):
"""Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei").
diff --git a/backend/app/routers/mealie.py b/backend/app/routers/mealie.py
new file mode 100644
index 0000000..2cab95b
--- /dev/null
+++ b/backend/app/routers/mealie.py
@@ -0,0 +1,149 @@
+"""Mealie einrichten und prüfen.
+
+Die Zugangsdaten nimmt dieser Router entgegen, gibt sie aber nie zurück. Die
+Oberfläche erfährt nur „eingerichtet ja/nein" — dasselbe Muster wie bei
+``GET /auth/oidc/providers``, wo Client-Secrets ebenfalls nie in der Antwort
+stehen.
+
+Bewusst eigene Routen mit JSON-Rumpf statt ``PUT /settings/{key}?value=``:
+Query-Parameter landen in Zugriffsprotokollen, und ein Passwort hat dort
+nichts zu suchen.
+"""
+
+# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py.
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from ..deps import get_current_user, require_admin
+from ..models import Setting, User
+from ..schemas import (
+ MealieAvailable,
+ MealieConfigIn,
+ MealieStatus,
+ MealieTokenIn,
+)
+from ..services import mealie
+
+router = APIRouter(prefix="/mealie", tags=["mealie"])
+
+
+def _setze_adresse(db: Session, base_url: str) -> str:
+ sauber = base_url.strip().rstrip("/")
+ row = db.get(Setting, mealie.BASE_URL_KEY)
+ if row is None:
+ db.add(Setting(key=mealie.BASE_URL_KEY, value=sauber))
+ else:
+ row.value = sauber
+ db.commit()
+ return sauber
+
+
+def _status(db: Session, pruefen: bool) -> MealieStatus:
+ adresse = mealie.basis_adresse(db)
+ hat_token = bool(mealie.token(db))
+ status_out = MealieStatus(
+ configured=bool(adresse) and hat_token,
+ base_url=adresse,
+ token_set=hat_token,
+ )
+ if not pruefen or not status_out.configured:
+ return status_out
+ try:
+ status_out.version = mealie.version(db)
+ # Eine echte Abfrage – „erreichbar" soll heissen, dass auch das Token gilt.
+ mealie.rezepte(db, pro_seite=1)
+ status_out.reachable = True
+ except mealie.MealieFehler as fehler:
+ status_out.reachable = False
+ status_out.error = str(fehler)
+ return status_out
+
+
+@router.get("/status", response_model=MealieStatus)
+def mealie_status(
+ pruefen: bool = False,
+ db: Session = Depends(get_db),
+ _: User = Depends(require_admin),
+) -> MealieStatus:
+ """Was eingerichtet ist – ohne das Token. ``pruefen=true`` fragt Mealie an."""
+ return _status(db, pruefen)
+
+
+@router.get("/available", response_model=MealieAvailable)
+def mealie_available(
+ db: Session = Depends(get_db),
+ _: User = Depends(get_current_user),
+) -> MealieAvailable:
+ """Ob die Rezept-Seite etwas anzeigen kann.
+
+ Für alle angemeldeten Benutzer, damit die Oberfläche den Menüpunkt nur dann
+ zeigt. Anders als bei den Anmeldeanbietern **mit** Anmeldung: Es gibt keinen
+ Grund, Unbekannten zu verraten, dass hier eine Mealie-Instanz hängt.
+ """
+ return MealieAvailable(available=mealie.eingerichtet(db))
+
+
+@router.put("/config", response_model=MealieStatus)
+def mealie_konfigurieren(
+ payload: MealieConfigIn,
+ db: Session = Depends(get_db),
+ _: User = Depends(require_admin),
+) -> MealieStatus:
+ """Mit Benutzername und Passwort anmelden und ein Dauertoken holen.
+
+ Das Passwort wird nur für diese eine Anmeldung benutzt und danach verworfen
+ – es steht in keiner Zeile der Datenbank und in keiner Antwort.
+ """
+ try:
+ dauertoken = mealie.hole_dauertoken(
+ payload.base_url, payload.username, payload.password
+ )
+ except mealie.MealieFehler as fehler:
+ raise HTTPException(_http_code(fehler), str(fehler)) from fehler
+ _setze_adresse(db, payload.base_url)
+ mealie.speichere_token(db, dauertoken)
+ return _status(db, pruefen=True)
+
+
+@router.put("/token", response_model=MealieStatus)
+def mealie_token_setzen(
+ payload: MealieTokenIn,
+ db: Session = Depends(get_db),
+ _: User = Depends(require_admin),
+) -> MealieStatus:
+ """Ein in Mealie selbst erzeugtes Dauertoken einsetzen.
+
+ Für alle, die ihr Passwort nicht eintippen wollen: In Mealie unter Profil ▸
+ API-Tokens eines anlegen und hier einfügen.
+ """
+ _setze_adresse(db, payload.base_url)
+ mealie.speichere_token(db, payload.token.strip())
+ return _status(db, pruefen=True)
+
+
+@router.delete("/config", status_code=status.HTTP_204_NO_CONTENT)
+def mealie_trennen(
+ db: Session = Depends(get_db),
+ _: User = Depends(require_admin),
+) -> None:
+ """Verbindung lösen: Adresse und Token hier entfernen.
+
+ Das Token selbst bleibt in Mealie bestehen – wer es dort endgültig los sein
+ will, löscht es unter Profil ▸ API-Tokens.
+ """
+ mealie.loesche_zugang(db)
+
+
+def _http_code(fehler: mealie.MealieFehler) -> int:
+ """Fehlerkennung auf einen Statuscode abbilden.
+
+ Getrennt, weil „Mealie ist weg" (502) etwas anderes ist als „dein Passwort
+ stimmt nicht" (400) – und die Oberfläche beides verschieden erklären soll.
+ """
+ if fehler.kennung in ("anmeldung_fehlgeschlagen",):
+ return status.HTTP_400_BAD_REQUEST
+ if fehler.kennung == "nicht_eingerichtet":
+ return status.HTTP_503_SERVICE_UNAVAILABLE
+ return status.HTTP_502_BAD_GATEWAY
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 954d5ab..eb622d0 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -694,6 +694,40 @@ class LotConsumeOut(BaseModel):
opened: bool = False
+# ---- Mealie ----
+class MealieConfigIn(BaseModel):
+ """Zugangsdaten für die einmalige Anmeldung bei Mealie.
+
+ Das Passwort wird **nicht** gespeichert: Vorrania meldet sich damit einmal
+ an, lässt sich ein Dauertoken ausstellen und vergisst es.
+ """
+ base_url: str = Field(min_length=1, max_length=255)
+ username: str = Field(min_length=1, max_length=120)
+ password: str = Field(min_length=1)
+
+
+class MealieTokenIn(BaseModel):
+ """Ein in Mealie selbst erzeugtes Dauertoken einsetzen (Alternative zum Passwort)."""
+ base_url: str = Field(min_length=1, max_length=255)
+ token: str = Field(min_length=1)
+
+
+class MealieStatus(BaseModel):
+ """Auskunft über die Verbindung – **nie** das Token selbst."""
+ configured: bool = False
+ base_url: str = ""
+ token_set: bool = False
+ # Erst bei einer echten Abfrage gefüllt; None heisst „nicht nachgesehen".
+ reachable: bool | None = None
+ version: str | None = None
+ error: str | None = None
+
+
+class MealieAvailable(BaseModel):
+ """Ob die Rezept-Seite überhaupt etwas anzeigen kann (für alle Benutzer)."""
+ available: bool = False
+
+
# ---- Hausmaße ----
class MeasureIn(BaseModel):
"""Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel."""
diff --git a/backend/app/services/mealie.py b/backend/app/services/mealie.py
new file mode 100644
index 0000000..92d57e6
--- /dev/null
+++ b/backend/app/services/mealie.py
@@ -0,0 +1,281 @@
+"""Anbindung an eine selbst gehostete Mealie-Instanz (Rezepte).
+
+Vorbild ist ``services/oidc.py``, nicht ``off.py``. Der Unterschied ist
+Absicht: Bei Open Food Facts ist „kein Treffer" der Normalfall und wird still
+zu ``None``. Hier ist „Mealie antwortet nicht" ein Fehler, den der Nutzer sehen
+muss — sonst stünde eine leere Rezeptliste da, und niemand wüsste, ob es keine
+Rezepte gibt oder der Server weg ist.
+
+Zugangsdaten: Der Admin tippt Adresse, Benutzername und Passwort **einmal**.
+Vorrania meldet sich damit an, lässt sich von Mealie ein Dauertoken ausstellen
+und verwirft das Passwort sofort. Gespeichert wird nur das Token — widerrufbar
+in Mealie unter Profil ▸ API-Tokens.
+"""
+
+from __future__ import annotations
+
+import time
+
+import httpx
+from sqlalchemy.orm import Session
+
+from ..config import get_settings
+from ..models import IntegrationSecret, Setting
+
+# Schlüssel in den Einstellungen bzw. in der Geheimnis-Tabelle.
+BASE_URL_KEY = "mealie_base_url"
+SECRET_SERVICE = "mealie"
+
+# Name des Tokens, wie er in Mealie erscheint – damit erkennbar ist, wer da
+# zugreift, und man es gezielt widerrufen kann.
+TOKEN_NAME = "Vorrania"
+
+CACHE_SEKUNDEN = 3600.0 # erkannte Pfadvariante
+REZEPTE_CACHE_SEKUNDEN = 60.0 # kurz: ein neues Rezept soll bald auftauchen
+
+# Pfadvariante je Adresse. Mealie 2.x legt die Haushalts-Routen unter
+# /api/households/, 1.x unter /api/groups/. Die Rezeptpfade sind in beiden
+# gleich; der Unterschied zählt erst für die Einkaufslisten.
+_variante_cache: dict[str, tuple[float, str]] = {}
+_rezepte_cache: dict[str, tuple[float, dict]] = {}
+
+# Fuer Tests austauschbar – ohne das muesste jeder Test einen Server starten.
+_transport: httpx.BaseTransport | None = None
+
+
+class MealieFehler(Exception):
+ """Mealie hat nicht geantwortet oder abgelehnt.
+
+ ``kennung`` ist die kurze Kennung für die Oberfläche, ``args[0]`` der
+ Klartext fürs Protokoll – wie ``OidcFehler``. **Das Passwort steht in
+ keinem der beiden.**
+ """
+
+ def __init__(self, kennung: str, text: str) -> None:
+ super().__init__(text)
+ self.kennung = kennung
+
+
+def cache_leeren() -> None:
+ """Nur für Tests und zum erzwungenen Neuladen."""
+ _variante_cache.clear()
+ _rezepte_cache.clear()
+
+
+def setze_transport(transport: httpx.BaseTransport | None) -> None:
+ """Nur für Tests: HTTP-Antworten vorgeben, ohne einen Server zu starten."""
+ global _transport
+ _transport = transport
+
+
+def _client() -> httpx.Client:
+ return httpx.Client(
+ timeout=get_settings().mealie_timeout_seconds,
+ headers={"User-Agent": "Vorrania-Selfhosted/1.0"},
+ follow_redirects=True,
+ transport=_transport,
+ )
+
+
+# --------------------------------------------------------------------------
+# Konfiguration
+# --------------------------------------------------------------------------
+def basis_adresse(db: Session) -> str:
+ row = db.get(Setting, BASE_URL_KEY)
+ return (row.value if row else "").strip().rstrip("/")
+
+
+def token(db: Session) -> str:
+ row = db.get(IntegrationSecret, SECRET_SERVICE)
+ return row.secret if row else ""
+
+
+def eingerichtet(db: Session) -> bool:
+ return bool(basis_adresse(db)) and bool(token(db))
+
+
+def _zugang(db: Session) -> tuple[str, str]:
+ basis, tok = basis_adresse(db), token(db)
+ if not basis or not tok:
+ raise MealieFehler(
+ "nicht_eingerichtet",
+ "Für Mealie sind noch keine Zugangsdaten hinterlegt.",
+ )
+ return basis, tok
+
+
+def speichere_token(db: Session, wert: str) -> None:
+ row = db.get(IntegrationSecret, SECRET_SERVICE)
+ if row is None:
+ db.add(IntegrationSecret(service=SECRET_SERVICE, secret=wert))
+ else:
+ row.secret = wert
+ db.commit()
+ cache_leeren()
+
+
+def loesche_zugang(db: Session) -> None:
+ row = db.get(IntegrationSecret, SECRET_SERVICE)
+ if row is not None:
+ db.delete(row)
+ adresse = db.get(Setting, BASE_URL_KEY)
+ if adresse is not None:
+ db.delete(adresse)
+ db.commit()
+ cache_leeren()
+
+
+# --------------------------------------------------------------------------
+# Einrichten: Passwort einmal, danach nur noch das Dauertoken
+# --------------------------------------------------------------------------
+def hole_dauertoken(base_url: str, username: str, password: str) -> str:
+ """Einmal anmelden, Dauertoken ausstellen lassen, zurückgeben.
+
+ 1. ``POST /api/auth/token`` mit Benutzername und Passwort als Formularfelder
+ liefert ein kurzlebiges Sitzungstoken.
+ 2. Damit ``POST /api/users/api-tokens`` → ein Dauertoken (rund fünf Jahre),
+ das genau einmal im Klartext in der Antwort steht.
+
+ Das Passwort verlässt diese Funktion nicht: Es steht in keinem Rückgabewert,
+ in keiner Fehlermeldung und in keiner Protokollzeile.
+ """
+ basis = base_url.strip().rstrip("/")
+ if not basis:
+ raise MealieFehler("nicht_eingerichtet", "Bitte die Adresse von Mealie angeben.")
+
+ with _client() as client:
+ try:
+ antwort = client.post(
+ f"{basis}/api/auth/token",
+ data={"username": username, "password": password},
+ )
+ except httpx.HTTPError as fehler:
+ raise MealieFehler(
+ "nicht_erreichbar", f"Mealie unter {basis} nicht erreichbar: {fehler}"
+ ) from fehler
+ if antwort.status_code in (401, 403):
+ raise MealieFehler(
+ "anmeldung_fehlgeschlagen",
+ "Benutzername oder Passwort stimmen bei Mealie nicht.",
+ )
+ if antwort.status_code >= 400:
+ raise MealieFehler(
+ "abgelehnt",
+ f"Mealie hat die Anmeldung abgelehnt (HTTP {antwort.status_code}).",
+ )
+ try:
+ sitzung = antwort.json().get("access_token")
+ except ValueError:
+ sitzung = None
+ if not sitzung:
+ raise MealieFehler(
+ "abgelehnt", "Mealie hat kein Sitzungstoken zurückgegeben."
+ )
+
+ try:
+ erstellt = client.post(
+ f"{basis}/api/users/api-tokens",
+ json={"name": TOKEN_NAME, "integration_id": "vorrania"},
+ headers={"Authorization": f"Bearer {sitzung}"},
+ )
+ except httpx.HTTPError as fehler:
+ raise MealieFehler(
+ "nicht_erreichbar", f"Mealie nicht erreichbar: {fehler}"
+ ) from fehler
+ if erstellt.status_code >= 400:
+ raise MealieFehler(
+ "kein_dauertoken",
+ f"Mealie wollte kein Dauertoken ausstellen (HTTP {erstellt.status_code}).",
+ )
+ try:
+ dauertoken = erstellt.json().get("token")
+ except ValueError:
+ dauertoken = None
+ if not dauertoken:
+ raise MealieFehler(
+ "kein_dauertoken", "Mealie hat kein Dauertoken zurückgegeben."
+ )
+ return dauertoken
+
+
+# --------------------------------------------------------------------------
+# Abfragen
+# --------------------------------------------------------------------------
+def _hole(db: Session, pfad: str) -> dict:
+ basis, tok = _zugang(db)
+ with _client() as client:
+ try:
+ antwort = client.get(
+ f"{basis}{pfad}", headers={"Authorization": f"Bearer {tok}"}
+ )
+ except httpx.HTTPError as fehler:
+ raise MealieFehler(
+ "nicht_erreichbar", f"Mealie unter {basis} nicht erreichbar: {fehler}"
+ ) from fehler
+ if antwort.status_code in (401, 403):
+ raise MealieFehler(
+ "token_fehlt",
+ "Mealie weist das hinterlegte Token zurück. Bitte die Verbindung erneuern.",
+ )
+ if antwort.status_code == 404:
+ raise MealieFehler("nicht_gefunden", f"Mealie kennt {pfad} nicht.")
+ if antwort.status_code >= 400:
+ raise MealieFehler(
+ "abgelehnt", f"Mealie antwortete mit HTTP {antwort.status_code} auf {pfad}."
+ )
+ try:
+ return antwort.json()
+ except ValueError as fehler:
+ raise MealieFehler("abgelehnt", "Mealie antwortete nicht mit JSON.") from fehler
+
+
+def pfadvariante(db: Session) -> str:
+ """„households" (Mealie 2.x) oder „groups" (1.x), gepuffert je Adresse.
+
+ Die Rezeptpfade sind in beiden Fassungen gleich; der Unterschied zählt erst
+ für die Einkaufslisten. Geprüft wird mit einer Anfrage: 404 heißt „falscher
+ Pfad", alles andere „richtiger". Bewusst nicht über die Versionsnummer —
+ die melden Installationen uneinheitlich.
+ """
+ basis = basis_adresse(db)
+ gepuffert = _variante_cache.get(basis)
+ if gepuffert and gepuffert[0] > time.monotonic():
+ return gepuffert[1]
+ variante = "households"
+ try:
+ _hole(db, "/api/households/shopping/lists?perPage=1")
+ except MealieFehler as fehler:
+ if fehler.kennung == "nicht_gefunden":
+ variante = "groups"
+ elif fehler.kennung != "abgelehnt":
+ raise
+ _variante_cache[basis] = (time.monotonic() + CACHE_SEKUNDEN, variante)
+ return variante
+
+
+def version(db: Session) -> str | None:
+ """Versionsangabe der Instanz, nur zur Anzeige. Fehlt sie, ist das kein Fehler."""
+ try:
+ return str(_hole(db, "/api/app/about").get("version") or "") or None
+ except MealieFehler:
+ return None
+
+
+def rezepte(db: Session, *, seite: int = 1, pro_seite: int = 50, suche: str | None = None) -> dict:
+ """Eine Seite der Rezeptliste. Kurz gepuffert, damit Blättern nicht hakt."""
+ pfad = f"/api/recipes?page={seite}&perPage={pro_seite}"
+ if suche:
+ pfad += f"&search={httpx.QueryParams({'s': suche})['s']}"
+ schluessel = f"{basis_adresse(db)}|{pfad}"
+ gepuffert = _rezepte_cache.get(schluessel)
+ if gepuffert and gepuffert[0] > time.monotonic():
+ return gepuffert[1]
+ daten = _hole(db, pfad)
+ _rezepte_cache[schluessel] = (time.monotonic() + REZEPTE_CACHE_SEKUNDEN, daten)
+ return daten
+
+
+def rezept(db: Session, slug: str) -> dict:
+ """Ein einzelnes Rezept samt Zutaten. Bewusst **nicht** gepuffert –
+ es wird gezielt geöffnet und soll dann aktuell sein."""
+ return _hole(db, f"/api/recipes/{slug}")
diff --git a/backend/tests/test_mealie.py b/backend/tests/test_mealie.py
new file mode 100644
index 0000000..ed77a44
--- /dev/null
+++ b/backend/tests/test_mealie.py
@@ -0,0 +1,303 @@
+"""Mealie-Anbindung: Anmelden, Dauertoken holen, und das Passwort vergessen.
+
+Ohne Netz: ``httpx.MockTransport`` liefert die Antworten, die eine echte
+Mealie-Instanz geben würde.
+"""
+
+import json
+
+import httpx
+import pytest
+from fastapi import HTTPException
+
+from app.models import IntegrationSecret, Role, Setting, User
+from app.routers.mealie import (
+ mealie_available,
+ mealie_konfigurieren,
+ mealie_status,
+ mealie_token_setzen,
+ mealie_trennen,
+)
+from app.routers.settings import list_settings
+from app.schemas import MealieConfigIn, MealieTokenIn
+from app.services import mealie
+
+
+@pytest.fixture()
+def admin(db):
+ person = User(username="admin", password_hash="x", role=Role.admin)
+ db.add(person)
+ db.commit()
+ db.refresh(person)
+ return person
+
+
+@pytest.fixture(autouse=True)
+def sauber():
+ """Caches und Transport zwischen den Tests zuruecksetzen."""
+ mealie.cache_leeren()
+ yield
+ mealie.setze_transport(None)
+ mealie.cache_leeren()
+
+
+def _mealie_server(*, passwort_ok=True, token_ok=True, erreichbar=True):
+ """Eine Mealie-Instanz nachstellen. Merkt sich, was sie gesehen hat."""
+ gesehen: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ gesehen.append(request)
+ if not erreichbar:
+ raise httpx.ConnectError("keine Verbindung", request=request)
+ pfad = request.url.path
+ if pfad == "/api/auth/token":
+ if not passwort_ok:
+ return httpx.Response(401, json={"detail": "Unauthorized"})
+ return httpx.Response(200, json={
+ "access_token": "kurzlebig", "token_type": "bearer", "expires_in": 3600,
+ })
+ if pfad == "/api/users/api-tokens":
+ if not token_ok:
+ return httpx.Response(403, json={"detail": "nope"})
+ return httpx.Response(201, json={"token": "dauertoken-xyz", "name": "Vorrania"})
+ if pfad == "/api/app/about":
+ return httpx.Response(200, json={"version": "2.4.1"})
+ if pfad == "/api/recipes":
+ return httpx.Response(200, json={"items": [], "total": 0, "page": 1})
+ if pfad == "/api/households/shopping/lists":
+ return httpx.Response(200, json={"items": []})
+ return httpx.Response(404, json={"detail": "Not found"})
+
+ mealie.setze_transport(httpx.MockTransport(handler))
+ return gesehen
+
+
+# --------------------------------------------------------------------------
+# Einrichten
+# --------------------------------------------------------------------------
+def test_einrichten_holt_ein_dauertoken(db, admin):
+ _mealie_server()
+
+ status = mealie_konfigurieren(
+ MealieConfigIn(base_url="https://mealie.example.com",
+ username="guido", password="geheim"),
+ db=db, _=admin,
+ )
+
+ assert status.configured is True
+ assert status.token_set is True
+ assert status.base_url == "https://mealie.example.com"
+ assert status.reachable is True
+ assert status.version == "2.4.1"
+ assert db.get(IntegrationSecret, "mealie").secret == "dauertoken-xyz"
+
+
+def test_passwort_wird_nirgends_gespeichert(db, admin):
+ """Der Kern der Zusage: Das Passwort sieht nur die Anmeldung."""
+ _mealie_server()
+
+ mealie_konfigurieren(
+ MealieConfigIn(base_url="https://mealie.example.com",
+ username="guido", password="streng-geheim"),
+ db=db, _=admin,
+ )
+
+ alle_werte = [s.value for s in db.query(Setting).all()]
+ alle_werte += [s.secret for s in db.query(IntegrationSecret).all()]
+ assert all("streng-geheim" not in wert for wert in alle_werte)
+
+
+def test_passwort_steht_nicht_im_fehlertext(db, admin):
+ _mealie_server(passwort_ok=False)
+
+ with pytest.raises(HTTPException) as fehler:
+ mealie_konfigurieren(
+ MealieConfigIn(base_url="https://mealie.example.com",
+ username="guido", password="streng-geheim"),
+ db=db, _=admin,
+ )
+
+ assert fehler.value.status_code == 400
+ assert "streng-geheim" not in str(fehler.value.detail)
+
+
+def test_adresse_wird_ohne_schraegstrich_abgelegt(db, admin):
+ _mealie_server()
+
+ mealie_konfigurieren(
+ MealieConfigIn(base_url="https://mealie.example.com/",
+ username="g", password="p"),
+ db=db, _=admin,
+ )
+
+ assert db.get(Setting, "mealie_base_url").value == "https://mealie.example.com"
+
+
+def test_falsches_passwort_wird_als_solches_gemeldet(db, admin):
+ _mealie_server(passwort_ok=False)
+
+ with pytest.raises(HTTPException) as fehler:
+ mealie_konfigurieren(
+ MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
+ db=db, _=admin,
+ )
+
+ assert fehler.value.status_code == 400
+ assert "Passwort" in str(fehler.value.detail)
+ assert db.get(IntegrationSecret, "mealie") is None
+
+
+def test_server_weg_ist_kein_passwortfehler(db, admin):
+ """Unterscheidung, die der Nutzer braucht: 502 statt 400."""
+ _mealie_server(erreichbar=False)
+
+ with pytest.raises(HTTPException) as fehler:
+ mealie_konfigurieren(
+ MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
+ db=db, _=admin,
+ )
+
+ assert fehler.value.status_code == 502
+
+
+def test_kein_dauertoken_wird_gemeldet(db, admin):
+ _mealie_server(token_ok=False)
+
+ with pytest.raises(HTTPException) as fehler:
+ mealie_konfigurieren(
+ MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
+ db=db, _=admin,
+ )
+
+ assert fehler.value.status_code == 502
+ assert db.get(IntegrationSecret, "mealie") is None
+
+
+def test_eigenes_token_einsetzen(db, admin):
+ _mealie_server()
+
+ status = mealie_token_setzen(
+ MealieTokenIn(base_url="https://m.example.com", token="selbst-erzeugt"),
+ db=db, _=admin,
+ )
+
+ assert status.configured is True
+ assert db.get(IntegrationSecret, "mealie").secret == "selbst-erzeugt"
+
+
+# --------------------------------------------------------------------------
+# Auskunft – und was sie NICHT verrät
+# --------------------------------------------------------------------------
+def test_status_gibt_das_token_nie_zurueck(db, admin):
+ _mealie_server()
+ mealie_konfigurieren(
+ MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
+ db=db, _=admin,
+ )
+
+ status = mealie_status(db=db, _=admin)
+
+ assert status.token_set is True
+ assert "dauertoken-xyz" not in json.dumps(status.model_dump(mode="json"))
+
+
+def test_einstellungen_enthalten_das_token_nicht(db, admin):
+ """GET /settings liest jeder angemeldete Nutzer – deshalb eigene Tabelle."""
+ _mealie_server()
+ mealie_konfigurieren(
+ MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
+ db=db, _=admin,
+ )
+
+ eintraege = list_settings(db=db, _=admin)
+
+ assert all("dauertoken-xyz" not in e.value for e in eintraege)
+ # Die Adresse darf dort stehen, sie ist kein Geheimnis.
+ assert any(e.key == "mealie_base_url" for e in eintraege)
+
+
+def test_ohne_einrichtung_ist_nichts_verfuegbar(db, admin):
+ assert mealie_available(db=db, _=admin).available is False
+ status = mealie_status(db=db, _=admin)
+ assert status.configured is False
+ assert status.reachable is None # gar nicht erst nachgefragt
+
+
+def test_nach_einrichtung_ist_es_verfuegbar(db, admin):
+ _mealie_server()
+ mealie_konfigurieren(
+ MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
+ db=db, _=admin,
+ )
+
+ assert mealie_available(db=db, _=admin).available is True
+
+
+def test_trennen_entfernt_adresse_und_token(db, admin):
+ _mealie_server()
+ mealie_konfigurieren(
+ MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
+ db=db, _=admin,
+ )
+
+ mealie_trennen(db=db, _=admin)
+
+ assert db.get(IntegrationSecret, "mealie") is None
+ assert db.get(Setting, "mealie_base_url") is None
+ assert mealie_available(db=db, _=admin).available is False
+
+
+def test_abgelaufenes_token_meldet_sich_deutlich(db, admin):
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(401, json={"detail": "expired"})
+
+ db.add(Setting(key="mealie_base_url", value="https://m.example.com"))
+ db.add(IntegrationSecret(service="mealie", secret="alt"))
+ db.commit()
+ mealie.setze_transport(httpx.MockTransport(handler))
+
+ status = mealie_status(pruefen=True, db=db, _=admin)
+
+ assert status.reachable is False
+ assert "Token" in (status.error or "")
+
+
+# --------------------------------------------------------------------------
+# Fassungsunterschied 1.x / 2.x
+# --------------------------------------------------------------------------
+def test_erkennt_die_haushalts_pfade(db, admin):
+ _mealie_server()
+ mealie_konfigurieren(
+ MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
+ db=db, _=admin,
+ )
+
+ assert mealie.pfadvariante(db) == "households"
+
+
+def test_faellt_auf_die_gruppen_pfade_zurueck(db, admin):
+ """Mealie 1.x kennt /api/households/ nicht."""
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path.startswith("/api/households/"):
+ return httpx.Response(404, json={"detail": "Not Found"})
+ return httpx.Response(200, json={"items": []})
+
+ db.add(Setting(key="mealie_base_url", value="https://alt.example.com"))
+ db.add(IntegrationSecret(service="mealie", secret="t"))
+ db.commit()
+ mealie.setze_transport(httpx.MockTransport(handler))
+
+ assert mealie.pfadvariante(db) == "groups"
+
+
+def test_pfadvariante_wird_gepuffert(db, admin):
+ gesehen = _mealie_server()
+ db.add(Setting(key="mealie_base_url", value="https://m.example.com"))
+ db.add(IntegrationSecret(service="mealie", secret="t"))
+ db.commit()
+
+ mealie.pfadvariante(db)
+ anzahl = len(gesehen)
+ mealie.pfadvariante(db)
+
+ assert len(gesehen) == anzahl # keine zweite Anfrage
diff --git a/web/src/api.js b/web/src/api.js
index b3e9d44..ace51a5 100644
--- a/web/src/api.js
+++ b/web/src/api.js
@@ -444,6 +444,13 @@ export const api = {
resetAll: () => request("/maintenance/reset/all", { method: "POST" }),
// Einstellungen
+ // Mealie: Adresse und Token nimmt der Server entgegen, gibt sie aber nie
+ // zurueck – die Oberflaeche erfaehrt nur „eingerichtet ja/nein".
+ mealieStatus: (pruefen = false) => request(`/mealie/status${pruefen ? "?pruefen=true" : ""}`),
+ mealieVerfuegbar: () => request("/mealie/available"),
+ mealieKonfigurieren: (body) => request("/mealie/config", { method: "PUT", body }),
+ mealieToken: (body) => request("/mealie/token", { method: "PUT", body }),
+ mealieTrennen: () => request("/mealie/config", { method: "DELETE" }),
listSettings: () => request("/settings"),
setSetting: (key, value) =>
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx
index 15a6726..f487fc4 100644
--- a/web/src/pages/Settings.jsx
+++ b/web/src/pages/Settings.jsx
@@ -171,6 +171,7 @@ export default function Settings() {
+