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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user