Add verified rollback for failed recipe imports
Co-authored-by: Bumble <bumble@agents.famfallman.com> Co-authored-by: fredamn76 <fredrik.fallman@gmail.com> Signed-off-by: fredamn76 <fredrik.fallman@gmail.com>
This commit is contained in:
@@ -63,6 +63,7 @@ campaign junk in the title. A response is not proof of a good import.
|
|||||||
|
|
||||||
**Write**
|
**Write**
|
||||||
- `patch_recipe(slug_or_id, patch)`
|
- `patch_recipe(slug_or_id, patch)`
|
||||||
|
- `delete_recipe(slug_or_id, confirm_slug)` — exact-slug confirmation plus 404 read-back proof
|
||||||
- `parse_ingredients(slug_or_id)`
|
- `parse_ingredients(slug_or_id)`
|
||||||
- `set_cover_image(slug_or_id, source_url=None, image_path=None)`
|
- `set_cover_image(slug_or_id, source_url=None, image_path=None)`
|
||||||
- `shopping_list_add(items)`
|
- `shopping_list_add(items)`
|
||||||
|
|||||||
@@ -335,6 +335,39 @@ def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return get_recipe(slug_or_id)
|
return get_recipe(slug_or_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def delete_recipe(slug_or_id: str, confirm_slug: str) -> dict[str, Any]:
|
||||||
|
"""Delete exactly one recipe, with explicit slug confirmation and read-back proof.
|
||||||
|
|
||||||
|
This is primarily the rollback path for failed imports and marked test recipes.
|
||||||
|
The caller must first read the recipe and pass its exact stored slug as
|
||||||
|
``confirm_slug``. Names and ids are not accepted as confirmation.
|
||||||
|
"""
|
||||||
|
recipe = get_recipe(slug_or_id)
|
||||||
|
stored_slug = recipe.get("slug") or ""
|
||||||
|
if confirm_slug != stored_slug:
|
||||||
|
raise ValueError(
|
||||||
|
f"Deletion confirmation does not match stored slug {stored_slug!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
with _client() as client:
|
||||||
|
response = client.delete(f"/api/recipes/{stored_slug}")
|
||||||
|
_raise_for_status(response)
|
||||||
|
probe = client.get(f"/api/recipes/{stored_slug}")
|
||||||
|
if probe.status_code != 404:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Delete returned {response.status_code}, but read-back returned "
|
||||||
|
f"{probe.status_code} instead of 404"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"deleted": True,
|
||||||
|
"slug": stored_slug,
|
||||||
|
"name": recipe.get("name"),
|
||||||
|
"delete_status": response.status_code,
|
||||||
|
"readback_status": probe.status_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _unit_records() -> list[dict[str, Any]]:
|
def _unit_records() -> list[dict[str, Any]]:
|
||||||
"""The instance's own unit table — the authority for what `dl` and `msk` mean."""
|
"""The instance's own unit table — the authority for what `dl` and `msk` mean."""
|
||||||
data = _request("GET", "/api/units", params={"perPage": 200})
|
data = _request("GET", "/api/units", params={"perPage": 200})
|
||||||
|
|||||||
@@ -126,6 +126,48 @@ class TestTextImport:
|
|||||||
assert server.import_recipe_text(text)["recipe"] == recipe
|
assert server.import_recipe_text(text)["recipe"] == recipe
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteRecipe:
|
||||||
|
def test_requires_the_exact_stored_slug(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"get_recipe",
|
||||||
|
lambda _slug: {"slug": "stored-slug", "name": "Test"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_client",
|
||||||
|
lambda: pytest.fail("must not delete without exact confirmation"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="stored-slug"):
|
||||||
|
server.delete_recipe("recipe-id", "wrong-slug")
|
||||||
|
|
||||||
|
def test_deletes_and_proves_the_recipe_is_gone(self, mock_mealie):
|
||||||
|
recipe = {"slug": "zzz-test", "name": "ZZZ Test"}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "DELETE":
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
if request.url.path == "/api/recipes/zzz-test":
|
||||||
|
# First GET is get_recipe; second GET is the deletion proof.
|
||||||
|
if getattr(handler, "read_once", False):
|
||||||
|
return httpx.Response(404, json={"detail": "Not Found"})
|
||||||
|
handler.read_once = True
|
||||||
|
return httpx.Response(200, json=recipe)
|
||||||
|
return httpx.Response(500)
|
||||||
|
|
||||||
|
mock_mealie(handler)
|
||||||
|
result = server.delete_recipe("zzz-test", "zzz-test")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"deleted": True,
|
||||||
|
"slug": "zzz-test",
|
||||||
|
"name": "ZZZ Test",
|
||||||
|
"delete_status": 200,
|
||||||
|
"readback_status": 404,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestParseIngredients:
|
class TestParseIngredients:
|
||||||
def test_reverts_structure_when_mealie_rewords_the_swedish_line(self, mock_mealie):
|
def test_reverts_structure_when_mealie_rewords_the_swedish_line(self, mock_mealie):
|
||||||
canonical = "4,7 dl basmatiris"
|
canonical = "4,7 dl basmatiris"
|
||||||
|
|||||||
Reference in New Issue
Block a user