172 lines
7.1 KiB
Python
172 lines
7.1 KiB
Python
|
|
"""Server-layer behaviour, driven through a mocked HTTP transport."""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import httpx
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from mealie_mcp import server
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture
|
|||
|
|
def mock_mealie(monkeypatch):
|
|||
|
|
"""Install a fake Mealie and record every request the server makes."""
|
|||
|
|
def install(handler):
|
|||
|
|
recorded: list[httpx.Request] = []
|
|||
|
|
|
|||
|
|
def wrapped(request: httpx.Request) -> httpx.Response:
|
|||
|
|
recorded.append(request)
|
|||
|
|
return handler(request)
|
|||
|
|
|
|||
|
|
def fake_client() -> httpx.Client:
|
|||
|
|
return httpx.Client(
|
|||
|
|
base_url="https://mealie.test",
|
|||
|
|
headers={"Authorization": "Bearer test", "User-Agent": server.UA},
|
|||
|
|
transport=httpx.MockTransport(wrapped),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(server, "_client", fake_client)
|
|||
|
|
return recorded
|
|||
|
|
|
|||
|
|
return install
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestErrorMapping:
|
|||
|
|
def test_401_is_reported_as_an_auth_problem(self, mock_mealie):
|
|||
|
|
mock_mealie(lambda r: httpx.Response(401, json={"detail": "Could not validate credentials"}))
|
|||
|
|
result = server.check_auth()
|
|||
|
|
assert result["ok"] is False
|
|||
|
|
assert result["reason"] == "auth"
|
|||
|
|
assert "token" in result["detail"]
|
|||
|
|
|
|||
|
|
def test_cloudflare_1010_is_not_mistaken_for_bad_credentials(self, mock_mealie):
|
|||
|
|
# Verified failure: the external hostname returns 403 + "error code: 1010"
|
|||
|
|
# for blocked client fingerprints. Calling that an auth error sends
|
|||
|
|
# debugging down the wrong path.
|
|||
|
|
mock_mealie(lambda r: httpx.Response(403, text="error code: 1010"))
|
|||
|
|
result = server.check_auth()
|
|||
|
|
assert result["reason"] == "waf"
|
|||
|
|
assert "1010" in result["detail"]
|
|||
|
|
|
|||
|
|
def test_healthy_instance_reports_ok(self, mock_mealie):
|
|||
|
|
mock_mealie(lambda r: httpx.Response(200, json={"username": "fredrik"}))
|
|||
|
|
result = server.check_auth()
|
|||
|
|
assert result["ok"] is True
|
|||
|
|
assert result["user"] == "fredrik"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestUserAgent:
|
|||
|
|
def test_requests_never_use_the_default_user_agent(self, mock_mealie):
|
|||
|
|
recorded = mock_mealie(lambda r: httpx.Response(200, json={"username": "fredrik"}))
|
|||
|
|
server.check_auth()
|
|||
|
|
agent = recorded[0].headers["user-agent"]
|
|||
|
|
assert agent == server.UA
|
|||
|
|
assert not agent.startswith(("python-httpx", "Python-urllib"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestDuplicateDetection:
|
|||
|
|
def test_import_stops_when_the_source_url_already_exists(self, mock_mealie):
|
|||
|
|
existing = {"slug": "kycklinggryta", "name": "Kycklinggryta", "orgURL": "https://koket.se/a"}
|
|||
|
|
mock_mealie(lambda r: httpx.Response(200, json={"items": [existing]}))
|
|||
|
|
result = server.import_recipe_url("https://koket.se/a")
|
|||
|
|
assert result["imported"] is False
|
|||
|
|
assert result["reason"] == "duplicate"
|
|||
|
|
|
|||
|
|
def test_trailing_slash_does_not_hide_a_duplicate(self, mock_mealie):
|
|||
|
|
existing = {"slug": "x", "name": "X", "orgURL": "https://koket.se/a/"}
|
|||
|
|
mock_mealie(lambda r: httpx.Response(200, json={"items": [existing]}))
|
|||
|
|
assert server.find_by_source_url("https://koket.se/a")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestImportReport:
|
|||
|
|
def test_report_flags_failed_extraction_and_junk_title(self):
|
|||
|
|
recipe = {
|
|||
|
|
"slug": "flaskfile",
|
|||
|
|
"name": "Fläskfilé med svampsås - se & gör",
|
|||
|
|
"recipeIngredient": [{"display": "Could not detect ingredients"}],
|
|||
|
|
"recipeInstructions": [],
|
|||
|
|
"image": "",
|
|||
|
|
}
|
|||
|
|
report = server._import_report(recipe)
|
|||
|
|
assert report["extraction_failed"] is True
|
|||
|
|
assert report["suggested_title"] == "Fläskfilé med svampsås"
|
|||
|
|
assert report["has_image_field"] is False
|
|||
|
|
|
|||
|
|
def test_clean_import_suggests_no_title_change(self):
|
|||
|
|
recipe = {"slug": "lax", "name": "Lax i ugn", "recipeIngredient": [], "image": "img"}
|
|||
|
|
assert server._import_report(recipe)["suggested_title"] is None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestParseIngredients:
|
|||
|
|
def test_swedish_display_text_survives_the_parser(self, mock_mealie):
|
|||
|
|
canonical = "4,7 dl basmatiris"
|
|||
|
|
recipe = {
|
|||
|
|
"slug": "ris",
|
|||
|
|
"recipeIngredient": [{"display": canonical, "note": canonical}],
|
|||
|
|
}
|
|||
|
|
seen: dict[str, object] = {}
|
|||
|
|
|
|||
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|||
|
|
import json as _json
|
|||
|
|
|
|||
|
|
if request.url.path == "/api/parser/ingredients":
|
|||
|
|
seen["sent"] = _json.loads(request.content)["ingredients"]
|
|||
|
|
# The parser returns mangled display text; it must not be stored.
|
|||
|
|
return httpx.Response(200, json=[{
|
|||
|
|
"ingredient": {
|
|||
|
|
"quantity": 4.7,
|
|||
|
|
"unit": {"id": "u", "name": "dl"},
|
|||
|
|
"food": {"id": "f", "name": "basmatiris"},
|
|||
|
|
"display": "47⁄10 liter basmatiris",
|
|||
|
|
}
|
|||
|
|
}])
|
|||
|
|
if request.method == "PATCH":
|
|||
|
|
seen["patched"] = _json.loads(request.content)["recipeIngredient"]
|
|||
|
|
return httpx.Response(200, json={})
|
|||
|
|
return httpx.Response(200, json=recipe)
|
|||
|
|
|
|||
|
|
mock_mealie(handler)
|
|||
|
|
server.parse_ingredients("ris")
|
|||
|
|
|
|||
|
|
# Decimal comma is normalized for the parser only...
|
|||
|
|
assert seen["sent"] == ["4.7 dl basmatiris"]
|
|||
|
|
patched = seen["patched"][0]
|
|||
|
|
# ...while the stored human-facing line stays the original Swedish text.
|
|||
|
|
assert patched["display"] == canonical
|
|||
|
|
assert patched["note"] == canonical
|
|||
|
|
# Structured data from the parser is still applied.
|
|||
|
|
assert patched["quantity"] == 4.7
|
|||
|
|
assert patched["food"]["name"] == "basmatiris"
|
|||
|
|
|
|||
|
|
def test_failed_structured_patch_preserves_the_readable_import(self, mock_mealie):
|
|||
|
|
recipe = {"slug": "ris", "recipeIngredient": [{"display": "2 dl grädde"}]}
|
|||
|
|
|
|||
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|||
|
|
if request.url.path == "/api/parser/ingredients":
|
|||
|
|
return httpx.Response(200, json=[{"ingredient": {"quantity": 2}}])
|
|||
|
|
if request.method == "PATCH":
|
|||
|
|
# Verified: this PATCH can 500 with a ValueError on this instance.
|
|||
|
|
return httpx.Response(500, json={"detail": "ValueError"})
|
|||
|
|
return httpx.Response(200, json=recipe)
|
|||
|
|
|
|||
|
|
mock_mealie(handler)
|
|||
|
|
result = server.parse_ingredients("ris")
|
|||
|
|
assert result["parsed"] is False
|
|||
|
|
assert result["canonical_lines"] == ["2 dl grädde"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestCoverImage:
|
|||
|
|
def test_local_upload_sends_the_required_extension_field(self, mock_mealie, tmp_path):
|
|||
|
|
# Verified: without the multipart `extension` field the upload fails validation.
|
|||
|
|
image = tmp_path / "cover.webp"
|
|||
|
|
image.write_bytes(b"fake")
|
|||
|
|
recorded = mock_mealie(lambda r: httpx.Response(200, json={"slug": "x", "image": "img"}))
|
|||
|
|
server.set_cover_image("x", image_path=str(image))
|
|||
|
|
upload = next(r for r in recorded if r.method == "PUT")
|
|||
|
|
body = upload.content.decode("utf-8", errors="replace")
|
|||
|
|
assert 'name="extension"' in body
|
|||
|
|
assert "webp" in body
|
|||
|
|
|
|||
|
|
def test_requires_a_source(self):
|
|||
|
|
with pytest.raises(ValueError):
|
|||
|
|
server.set_cover_image("x")
|