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>
326 lines
11 KiB
Python
326 lines
11 KiB
Python
"""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)
|