Files
mealie-mcp/tests/test_server.py
T
Fizz f2d233b430 Initial commit: Mealie MCP server
Ports a working Hermes agent skill to an MCP server. The skill's value was not
its Mealie endpoints but ~25 operational rules found by running imports against
the live instance; those are now code with tests rather than prompt text.

The server owns deterministic mechanics — auth, the non-default User-Agent
Cloudflare requires, the `extension` field on cover uploads, PATCH instead of
the 500-ing bulk-action endpoints, decimal-comma normalization, and restoring
Swedish display text after parsing. Language and taste judgement stay with the
model, fed by the four reference notes shipped as MCP resources.

verify_recipe runs the finished-import definition as code so an agent cannot
report success on a recipe with an English ingredient line, a missing cover, or
ingredients still in the "Click Parse" state.

Home Assistant is optional and isolated; the Obsidian meal plan is out of scope.

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

172 lines
7.1 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.
"""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": "4710 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")