Address recipes by slug on sub-routes, and tags as real objects
Two failures from today's TikTok import, both misread as faults on the
Mealie instance.
set_cover_image passed slug_or_id straight into /api/recipes/{slug}/image.
That path only resolves slugs: /api/recipes/{slug} itself accepts an id, the
routes beneath it do not. With the recipe's id the scrape POST answered 404
and the file upload PUT answered 500 — neither of which says "wrong key", so
the agent concluded the image endpoint was broken and gave up on the cover.
Verified on this instance (v3.22.0): GET /api/recipes/<id>/comments answers
500 while the same call with the slug answers 200. The recipe is now read
first and its stored slug used for the sub-route.
patch_recipe sent tags through untouched, so {"name": "Källa: TikTok"} went
out as-is and came back 422. RecipeTag and RecipeCategory both require name
*and* slug per the instance's OpenAPI schema. Tags and categories are now
resolved before the PATCH: an existing organizer is reused (case-insensitive
on name) so tagging cannot fork a vocabulary that already holds "Källa:
Instagram" and "Källa: YouTube", and a genuinely new name is created through
its own endpoint, which supplies the slug Mealie itself would pick.
Neither path had a test that could have caught this: the cover tests always
passed a slug, and the patch contract test asserted the 422-producing shape
as if it were correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -337,7 +337,91 @@ class TestParseIngredients:
|
||||
assert result["canonical_lines"] == ["2 dl grädde"]
|
||||
|
||||
|
||||
class TestOrganizerPatch:
|
||||
"""Tags and categories must reach Mealie as full RecipeTag/RecipeCategory objects.
|
||||
|
||||
Verified against the instance's OpenAPI schema (v3.22.0): both require ``name``
|
||||
*and* ``slug``, so a bare ``{"name": ...}`` is answered with 422.
|
||||
"""
|
||||
|
||||
def _mealie(self, mock_mealie, existing, recipe=None):
|
||||
recipe = recipe or {"id": "stable-id", "slug": "soppa"}
|
||||
bodies: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path
|
||||
if path.startswith("/api/organizers/"):
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={"items": existing})
|
||||
created = json.loads(request.content)
|
||||
made = {"id": "new-id", "name": created["name"], "slug": "kalla-tiktok"}
|
||||
bodies.append({"created": made})
|
||||
return httpx.Response(201, json=made)
|
||||
if request.method == "PATCH":
|
||||
bodies.append({"patch": json.loads(request.content)})
|
||||
return httpx.Response(200, json=recipe)
|
||||
|
||||
return bodies, mock_mealie(handler)
|
||||
|
||||
def test_a_bare_name_is_created_and_patched_with_its_slug(self, mock_mealie):
|
||||
bodies, _ = self._mealie(mock_mealie, existing=[])
|
||||
|
||||
server.patch_recipe("soppa", {"tags": [{"name": "Källa: TikTok"}]})
|
||||
|
||||
created = [b["created"] for b in bodies if "created" in b]
|
||||
patch = next(b["patch"] for b in bodies if "patch" in b)
|
||||
assert created == [{"id": "new-id", "name": "Källa: TikTok", "slug": "kalla-tiktok"}]
|
||||
assert patch["tags"] == created
|
||||
|
||||
def test_an_existing_organizer_is_reused_instead_of_forking_the_vocabulary(self, mock_mealie):
|
||||
existing = [{"id": "tag-1", "name": "Källa: TikTok", "slug": "kalla-tiktok"}]
|
||||
bodies, recorded = self._mealie(mock_mealie, existing=existing)
|
||||
|
||||
server.patch_recipe("soppa", {"tags": ["källa: tiktok"]})
|
||||
|
||||
patch = next(b["patch"] for b in bodies if "patch" in b)
|
||||
assert patch["tags"] == existing
|
||||
assert not any(r.method == "POST" for r in recorded)
|
||||
|
||||
def test_a_complete_object_is_passed_through_untouched(self, mock_mealie):
|
||||
tag = {"id": "tag-1", "name": "📅 Vardag", "slug": "vardag"}
|
||||
bodies, recorded = self._mealie(mock_mealie, existing=[])
|
||||
|
||||
server.patch_recipe("soppa", {"tags": [tag], "description": "Av: Matprinsessan"})
|
||||
|
||||
patch = next(b["patch"] for b in bodies if "patch" in b)
|
||||
assert patch == {"tags": [tag], "description": "Av: Matprinsessan"}
|
||||
assert not any(r.url.path.startswith("/api/organizers/") for r in recorded)
|
||||
|
||||
def test_categories_go_through_their_own_endpoint(self, mock_mealie):
|
||||
_, recorded = self._mealie(mock_mealie, existing=[])
|
||||
|
||||
server.patch_recipe("soppa", {"recipeCategory": ["Såser"]})
|
||||
|
||||
lookups = [r.url.path for r in recorded if r.url.path.startswith("/api/organizers/")]
|
||||
assert lookups == ["/api/organizers/categories", "/api/organizers/categories"]
|
||||
|
||||
|
||||
class TestCoverImage:
|
||||
def test_an_id_is_resolved_to_the_slug_before_hitting_the_image_route(self, mock_mealie):
|
||||
# Verified on v3.22.0: /api/recipes/{slug} takes an id, but the sub-routes
|
||||
# under it are slug-only — an id answered 404 on POST .../image and 500 on
|
||||
# PUT .../image, which is what silently killed the TikTok cover.
|
||||
recipe_id = "1488dd5c-abbe-4660-b61b-eeca3f6cfb91"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/image"):
|
||||
assert request.url.path == "/api/recipes/skolans-ost-broccolisas/image"
|
||||
return httpx.Response(200, json=None)
|
||||
return httpx.Response(200, json={"slug": "skolans-ost-broccolisas", "image": "img"})
|
||||
|
||||
recorded = mock_mealie(handler)
|
||||
|
||||
result = server.set_cover_image(recipe_id, source_url="https://cdn.test/cover.jpg")
|
||||
|
||||
assert result["slug"] == "skolans-ost-broccolisas"
|
||||
assert not any(recipe_id in str(r.url) and r.url.path.endswith("/image") for r in recorded)
|
||||
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user