Make ingredient parsing safe against live Mealie rewrites

Co-authored-by: Fizz <fizz@agents.famfallman.com>
Co-authored-by: fredamn76 <fredrik.fallman@gmail.com>
Signed-off-by: fredamn76 <fredrik.fallman@gmail.com>
This commit is contained in:
2026-07-31 08:38:11 +02:00
committed by fredamn76
parent ddc0fb1a5e
commit 1f10bd321c
4 changed files with 300 additions and 47 deletions
+35 -17
View File
@@ -97,45 +97,63 @@ class TestImportReport:
class TestParseIngredients:
def test_swedish_display_text_survives_the_parser(self, mock_mealie):
def test_reverts_structure_when_mealie_rewords_the_swedish_line(self, mock_mealie):
canonical = "4,7 dl basmatiris"
recipe = {
"slug": "ris",
"recipeIngredient": [{"display": canonical, "note": canonical}],
}
seen: dict[str, object] = {}
patches: list[list[dict]] = []
reads = 0
def handler(request: httpx.Request) -> httpx.Response:
import json as _json
nonlocal reads
if request.url.path == "/api/parser/ingredients":
seen["sent"] = _json.loads(request.content)["ingredients"]
# The parser returns mangled display text; it must not be stored.
return httpx.Response(200, json=[{
"ingredient": {
"quantity": 4.7,
"unit": {"id": "u", "name": "dl"},
"unit": {"id": "liter", "name": "liter"},
"food": {"id": "f", "name": "basmatiris"},
"display": "4710 liter basmatiris",
}
}])
if request.url.path == "/api/units":
return httpx.Response(
200,
json={"items": [{"id": "dl", "name": "dl", "abbreviation": "dl"}]},
)
if request.method == "PATCH":
seen["patched"] = _json.loads(request.content)["recipeIngredient"]
patches.append(_json.loads(request.content)["recipeIngredient"])
return httpx.Response(200, json={})
reads += 1
if reads == 1:
return httpx.Response(200, json=recipe)
if len(patches) == 1:
# This is what Mealie rendered from the first structured patch.
return httpx.Response(200, json={
"slug": "ris",
"recipeIngredient": [{
**patches[0][0],
"display": "4710 dl basmatiris",
}],
})
return httpx.Response(200, json=recipe)
mock_mealie(handler)
server.parse_ingredients("ris")
result = server.parse_ingredients("ris")
# Decimal comma is normalized for the parser only...
assert seen["sent"] == ["4.7 dl basmatiris"]
patched = seen["patched"][0]
# ...while the stored human-facing line stays the original Swedish text.
assert patched["display"] == canonical
assert patched["note"] == canonical
# Structured data from the parser is still applied.
assert patched["quantity"] == 4.7
assert patched["food"]["name"] == "basmatiris"
assert len(patches) == 2
assert patches[0][0]["unit"]["id"] == "dl"
assert patches[1][0]["quantity"] == 0
assert patches[1][0]["unit"] is None
assert patches[1][0]["food"] is None
assert patches[1][0]["note"] == canonical
assert result["lines_unchanged"] is True
assert result["left_unstructured"] == [{
"line": canonical,
"mealie_would_show": "4710 dl basmatiris",
}]
def test_failed_structured_patch_preserves_the_readable_import(self, mock_mealie):
recipe = {"slug": "ris", "recipeIngredient": [{"display": "2 dl grädde"}]}