"""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_reverts_structure_when_mealie_rewords_the_swedish_line(self, mock_mealie): canonical = "4,7 dl basmatiris" recipe = { "slug": "ris", "recipeIngredient": [{"display": canonical, "note": canonical}], } patches: list[list[dict]] = [] reads = 0 def handler(request: httpx.Request) -> httpx.Response: import json as _json nonlocal reads if request.url.path == "/api/parser/ingredients": return httpx.Response(200, json=[{ "ingredient": { "quantity": 4.7, "unit": {"id": "liter", "name": "liter"}, "food": {"id": "f", "name": "basmatiris"}, } }]) if request.url.path == "/api/units": return httpx.Response( 200, json={"items": [{"id": "dl", "name": "dl", "abbreviation": "dl"}]}, ) if request.method == "PATCH": patches.append(_json.loads(request.content)["recipeIngredient"]) return httpx.Response(200, json={}) reads += 1 if reads == 1: return httpx.Response(200, json=recipe) if len(patches) == 1: # This is what Mealie rendered from the first structured patch. return httpx.Response(200, json={ "slug": "ris", "recipeIngredient": [{ **patches[0][0], "display": "47⁄10 dl basmatiris", }], }) return httpx.Response(200, json=recipe) mock_mealie(handler) result = server.parse_ingredients("ris") assert len(patches) == 2 assert patches[0][0]["unit"]["id"] == "dl" assert patches[1][0]["quantity"] == 0 assert patches[1][0]["unit"] is None assert patches[1][0]["food"] is None assert patches[1][0]["note"] == canonical assert result["lines_unchanged"] is True assert result["left_unstructured"] == [{ "line": canonical, "mealie_would_show": "47⁄10 dl 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")