Write text imports through the recipe API, not the scraper

import_recipe_text posted plain Swedish text to
/api/recipes/create/html-or-json. That endpoint is a scraper entry point:
Mealie runs recipe_scrapers over whatever `data` holds and answers 400 for
anything without schema.org markup. A text block never had any, so the tool
returned 400 on every call it has ever made — including a minimal one-line
test recipe. The seven session logs of the importing agent contain no
successful text import; every recipe that landed came in through
import_recipe_url.

The existing test mocked _request away, so it asserted the payload shape and
never that Mealie would accept it. Green test, broken tool.

Create the recipe with POST /api/recipes and fill it with a PATCH instead.
Both are already exercised against this instance. Ingredients are stored as
readable `note` lines, which is what parse_ingredients expects to structure
afterwards.

Also accept a parenthetical on a section heading. `Gör så här (REKONSTRUERAD):`
marks where the steps came from, and the old exact-match check turned that
import away before it reached Mealie.

A PATCH that fails now leaves a name-only recipe, so the error names the slug
rather than leaving a silent empty stub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 14:20:31 +02:00
parent 67dac16059
commit 71c7a3414e
2 changed files with 166 additions and 9 deletions
+93
View File
@@ -1,6 +1,8 @@
"""Server-layer behaviour, driven through a mocked HTTP transport."""
from __future__ import annotations
import json
import httpx
import pytest
@@ -125,6 +127,97 @@ class TestTextImport:
assert server.import_recipe_text(text)["recipe"] == recipe
def test_text_is_never_posted_to_the_scraper_endpoint(self, mock_mealie, monkeypatch):
"""The scraper answers 400 for anything without schema.org markup.
Plain Swedish text has none, so every text import through
``/api/recipes/create/html-or-json`` failed. Pin the endpoints instead of
the payload, because that is what the mocked-out tests could not see.
"""
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/api/recipes/create/html-or-json":
return httpx.Response(400, json={"detail": "no recipe data found"})
if request.method == "POST":
return httpx.Response(201, json="testsoppa")
return httpx.Response(200, json={"slug": "testsoppa"})
recorded = mock_mealie(handler)
monkeypatch.setattr(server, "get_recipe", lambda _slug: {"slug": "testsoppa"})
server.import_recipe_text(
"Titel: Testsoppa\n\nPortioner: 2\n\nIngredienser:\n1 dl vatten\n\n"
"Gör så här:\nBlanda."
)
paths = [(r.method, r.url.path) for r in recorded]
assert ("POST", "/api/recipes") in paths
assert ("PATCH", "/api/recipes/testsoppa") in paths
assert all(path != "/api/recipes/create/html-or-json" for _method, path in paths)
def test_sections_become_readable_lines_steps_and_yield(self, mock_mealie, monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(201, json="testsoppa")
return httpx.Response(200, json={"slug": "testsoppa"})
recorded = mock_mealie(handler)
monkeypatch.setattr(server, "get_recipe", lambda _slug: {"slug": "testsoppa"})
server.import_recipe_text(
"Titel: Testsoppa\n\nPortioner: 4-6\n\nIngredienser:\n1 gul lök\n2 dl riven ost\n\n"
"Gör så här:\n1. Hacka löken.\n2. Rör ner osten.",
source_url="https://example.test/klipp",
)
create = json.loads(recorded[0].content)
patch = json.loads(recorded[1].content)
assert create == {"name": "Testsoppa"}
assert [item["note"] for item in patch["recipeIngredient"]] == ["1 gul lök", "2 dl riven ost"]
assert [step["text"] for step in patch["recipeInstructions"]] == [
"Hacka löken.",
"Rör ner osten.",
]
assert patch["recipeYield"] == "4-6"
assert patch["orgURL"] == "https://example.test/klipp"
def test_a_marked_reconstruction_heading_is_still_a_heading(self, mock_mealie, monkeypatch):
"""``Gör så här (REKONSTRUERAD):`` marks the source, not the section name.
The old exact-match check rejected it, which is how a correctly marked
import got turned away before it reached Mealie.
"""
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(201, json="testsoppa")
return httpx.Response(200, json={"slug": "testsoppa"})
recorded = mock_mealie(handler)
monkeypatch.setattr(server, "get_recipe", lambda _slug: {"slug": "testsoppa"})
server.import_recipe_text(
"Titel: Testsoppa\n\nIngredienser:\n1 dl vatten\n\n"
"Gör så här (REKONSTRUERAD - står INTE i källan):\nBlanda."
)
patch = json.loads(recorded[1].content)
assert [step["text"] for step in patch["recipeInstructions"]] == ["Blanda."]
def test_a_failed_content_patch_names_the_empty_recipe_it_left(
self, mock_mealie, monkeypatch
):
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(201, json="testsoppa")
return httpx.Response(500, json={"detail": "boom"})
mock_mealie(handler)
monkeypatch.setattr(server, "get_recipe", lambda _slug: pytest.fail("patch failed"))
with pytest.raises(RuntimeError, match="testsoppa"):
server.import_recipe_text(
"Titel: Testsoppa\n\nIngredienser:\n1 dl vatten\n\nGör så här:\nBlanda."
)
class TestDeleteRecipe:
def test_requires_the_exact_stored_slug(self, monkeypatch):