Files
Vorrania/backend/tests/test_mealie.py
T
ScarriffleandClaude Opus 5 18481bbca0 Seitenzustand in die Adresszeile
Neu web/src/urlState.js mit useUrlWert/useUrlZahl/useUrlWerte. Zwei
Regeln stecken darin, beide damit die Adresszeile brauchbar bleibt:
geschrieben wird immer mit replace - sonst legt jeder Tastendruck in
einem Suchfeld einen Verlaufseintrag an und die Zurueck-Taste braeuchte
zwanzig Versuche, um die Seite zu verlassen -, und Vorgabewerte fliegen
aus der URL, sonst sammelt sich "?typ=&q=&kat=" an.

useUrlWerte gibt es, weil zwei getrennte Schreibvorgaenge im selben
Durchlauf einander ueberschreiben: Beide bekommen denselben
Ausgangsstand. Wo ein Klick zwei Dinge aendert ("Kategorie 12, Karte
Masse"), muss es ein Aufruf sein.

Damit wandern in die URL:

- Rezepte: Das Rezept steht jetzt im PFAD (/rezepte/<slug>), weil es
  benennt, was man ansieht - so wie /products/:id und /i/:uid. Portionen
  und Suchbegriff als Parameter. "Pfannkuchen fuer 12" ist damit ein
  verschickbarer Link.
- Kategorien: Segment-Umschalter und die geoeffnete Seitenkarte
  (?typ=food&kat=6&panel=masse). Gespeichert wird die Kennung, nicht das
  Objekt - die Kategorien kommen erst vom Server.
- Gruppen: die geoeffnete Seitenkarte.
- Artikel: der Segment-Umschalter.
- Ein- und Auslagern: der gewaehlte Artikel als Kennung, nachgeladen beim
  Aufruf. Eine halbfertige Buchung ueberlebt damit ein Neuladen.

Die Portionszahl landet NUR in der URL, wenn jemand sie selbst gestellt
hat. Die Vorbelegung aus dem Rezept bleibt draussen - sonst traegt jedes
geoeffnete Rezept ein ?portionen mit sich herum, das gar keine
Entscheidung ist.

Dazu im Backend: Ein unbekanntes Rezept ist jetzt 404 statt 502 und sagt
"Dieses Rezept gibt es in Mealie nicht (mehr)" statt "Mealie kennt
/api/recipes/x nicht". Das wurde noetig, weil ein Rezept seit diesem
Commit verlinkbar ist und ein alter Link irgendwann ins Leere zeigt -
eine gewoehnliche Auskunft, kein Gateway-Fehler.

Von Hand durchgeklickt: Rezept oeffnen und Portionen auf 12 ->
/rezepte/pfannkuchen?portionen=12, Neuladen bringt 250 g -> 750 g zurueck;
unbekannter Slug zeigt den klaren Hinweis und laesst die Liste stehen;
Kategorie-Karte und Segment ueberstehen F5, Schliessen raeumt die URL;
/checkin?artikel=1 laedt den Artikel nach, ?artikel=999 raeumt die
Adresse und zeigt die Suchmaske. 1 neuer Test, Suite bei 518.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 16:13:37 +02:00

326 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.recipes import get_recipe
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
def test_unbekanntes_rezept_ist_ein_404_mit_klarem_text(db, admin):
"""Seit das Rezept im Pfad steht, kann ein Link ins Leere zeigen.
Das ist eine gewoehnliche Auskunft und kein Gateway-Fehler - und der Text
landet in der Oberflaeche, darf also nicht nach Innenleben klingen.
"""
db.add(Setting(key="mealie_base_url", value="https://m.example.com"))
db.add(IntegrationSecret(service="mealie", secret="t"))
db.commit()
mealie.setze_transport(httpx.MockTransport(
lambda request: httpx.Response(404, json={"detail": "Not found"}),
))
with pytest.raises(HTTPException) as fehler:
get_recipe("gibtsnicht", db=db, _=admin)
assert fehler.value.status_code == 404
assert "Rezept" in str(fehler.value.detail)
assert "/api/" not in str(fehler.value.detail)