Files
mealie-mcp/tests/test_server_contracts.py
T

125 lines
4.0 KiB
Python
Raw Normal View History

2026-07-31 07:31:10 +02:00
"""Boundary contracts for the verified Mealie instance quirks."""
from __future__ import annotations
2026-07-31 10:35:34 +02:00
import asyncio
2026-07-31 07:31:10 +02:00
import httpx
import pytest
2026-07-31 10:35:34 +02:00
from mcp.server.mcpserver import MCPServer
2026-07-31 07:31:10 +02:00
from mealie_mcp import server
def response(status: int, text: str = "") -> httpx.Response:
return httpx.Response(status, text=text, request=httpx.Request("GET", "https://example.test"))
class TestAuthenticationFailures:
def test_401_is_an_auth_problem(self):
with pytest.raises(server.MealieAuthError):
server._raise_for_status(response(401))
def test_cloudflare_1010_is_not_reported_as_bad_token(self):
with pytest.raises(server.MealieBlockedError):
server._raise_for_status(response(403, "error code: 1010"))
2026-07-31 10:35:34 +02:00
def test_read_only_server_removes_all_mutating_tools():
test_server = MCPServer("test")
@test_server.tool()
def search_recipes(query: str) -> list[str]:
return [query]
@test_server.tool()
def patch_recipe(value: str) -> str:
2026-07-31 10:35:34 +02:00
return value
@test_server.tool()
def future_mutating_tool(value: str) -> str:
return value
2026-07-31 10:35:34 +02:00
server._configure_read_only(test_server, frozenset({"search_recipes"}))
2026-07-31 10:35:34 +02:00
tool_names = {tool.name for tool in asyncio.run(test_server.list_tools())}
assert tool_names == {"search_recipes"}
def test_read_only_tool_allowlist_is_explicit():
assert server.READ_ONLY_TOOL_NAMES == {
"check_auth",
"find_by_source_url",
"get_recipe",
"list_organizers",
"resolve_foods",
"scale_ingredients",
"search_recipes",
"suggest_recipes",
"verify_recipe",
}
2026-07-31 07:31:10 +02:00
class TestUrlImportResponse:
def test_slug_only_import_response_is_resolved_before_reporting(self, monkeypatch):
recipe = {"slug": "lax-med-citron", "name": "Lax med citron", "recipeIngredient": []}
calls: list[tuple[str, str]] = []
def request(method, path, **kwargs):
calls.append((method, path))
assert kwargs["json"] == {"url": "https://example.test/lax"}
return "lax-med-citron"
monkeypatch.setattr(server, "find_by_source_url", lambda _url: [])
monkeypatch.setattr(server, "_request", request)
monkeypatch.setattr(server, "get_recipe", lambda slug: recipe if slug == "lax-med-citron" else None)
result = server.import_recipe_url("https://example.test/lax")
assert calls == [("POST", "/api/recipes/create/url")]
assert result["imported"] is True
assert result["recipe"] == recipe
assert result["report"]["slug"] == "lax-med-citron"
def test_duplicate_source_url_stops_before_creating(self, monkeypatch):
monkeypatch.setattr(
server,
"find_by_source_url",
lambda _url: [{"slug": "redan-finns", "name": "Redan finns"}],
)
monkeypatch.setattr(server, "_request", lambda *_args, **_kwargs: pytest.fail("must not import"))
result = server.import_recipe_url("https://example.test/recept")
assert result == {
"imported": False,
"reason": "duplicate",
"existing": [{"slug": "redan-finns", "name": "Redan finns"}],
}
class TestPatchContract:
def test_patch_recipe_reads_back_the_final_object(self, monkeypatch):
seen = []
2026-07-31 09:46:26 +02:00
before = {"id": "stable-id", "slug": "soppa", "name": "Soppa"}
updated = {
"id": "stable-id",
"slug": "ny-soppa",
"name": "Ny soppa",
"tags": [{"name": "📅 Vardag"}],
}
2026-07-31 07:31:10 +02:00
def request(method, path, **kwargs):
seen.append((method, path, kwargs))
return None
monkeypatch.setattr(server, "_request", request)
2026-07-31 09:46:26 +02:00
monkeypatch.setattr(
server,
"get_recipe",
lambda key: before if key == "soppa" else updated if key == "stable-id" else None,
)
2026-07-31 07:31:10 +02:00
assert server.patch_recipe("soppa", {"tags": updated["tags"]}) == updated
assert seen == [("PATCH", "/api/recipes/soppa", {"json": {"tags": updated["tags"]}})]