Store nutrition per serving, and check the estimate before sending it
Recipes imported from a video caption have no nutrition data, so the values have to be estimated. Mealie will accept anything: the fields are free-text strings, a misspelt key is dropped silently, and a whole-recipe total looks exactly like a per-serving one. The recipe page then renders whatever landed as fact. patch_recipe now validates a nutrition block first. Keys must be Mealie's own, so a number cannot vanish into "carbs". Values are normalized to bare numbers, matching how the library already stores them. Energy is checked against the macros with the Atwater factors (4/9/4 kcal per gram) and refused if it is more than 25% off, which is what catches an arithmetic slip. Nutrition is per serving, and the text import left recipeServings at 0 --- it set only the free-text recipeYield, so "4-6 personer" gave Mealie no number to divide by or scale with. import_recipe_text now also sets recipeServings, from the lower bound of a range, which is how this library already stores "10-12 personer" (recipeServings 10). patch_recipe refuses nutrition while the count is still missing, and accepts it when the same patch supplies it. verify_recipe fails an import that has no nutrition, or has values without a serving count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -179,6 +179,27 @@ class TestTextImport:
|
||||
]
|
||||
assert patch["recipeYield"] == "4-6"
|
||||
assert patch["orgURL"] == "https://example.test/klipp"
|
||||
# A range takes its lower bound; without a number Mealie cannot scale
|
||||
# ingredients or show nutrition per serving.
|
||||
assert patch["recipeServings"] == 4
|
||||
|
||||
def test_a_yield_without_a_number_leaves_servings_alone(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: en stor kastrull\n\nIngredienser:\n1 dl vatten\n\n"
|
||||
"Gör så här:\nBlanda."
|
||||
)
|
||||
|
||||
patch = json.loads(recorded[1].content)
|
||||
assert patch["recipeYield"] == "en stor kastrull"
|
||||
assert "recipeServings" not in patch
|
||||
|
||||
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.
|
||||
@@ -402,6 +423,81 @@ class TestOrganizerPatch:
|
||||
assert lookups == ["/api/organizers/categories", "/api/organizers/categories"]
|
||||
|
||||
|
||||
class TestNutritionPatch:
|
||||
"""Mealie validates nothing here: it stores strings and renders them as fact."""
|
||||
|
||||
def _mealie(self, mock_mealie, recipe):
|
||||
sent: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "PATCH":
|
||||
sent.append(json.loads(request.content))
|
||||
return httpx.Response(200, json=recipe)
|
||||
|
||||
mock_mealie(handler)
|
||||
return sent
|
||||
|
||||
def test_a_consistent_estimate_is_normalized_to_bare_numbers(self, mock_mealie):
|
||||
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 4})
|
||||
|
||||
server.patch_recipe(
|
||||
"sas",
|
||||
{"nutrition": {
|
||||
"calories": "459 kcal",
|
||||
"proteinContent": 13,
|
||||
"fatContent": "38 g",
|
||||
"carbohydrateContent": 15.4,
|
||||
}},
|
||||
)
|
||||
|
||||
assert sent[0]["nutrition"] == {
|
||||
"calories": "459",
|
||||
"proteinContent": "13",
|
||||
"fatContent": "38",
|
||||
"carbohydrateContent": "15",
|
||||
}
|
||||
|
||||
def test_energy_that_contradicts_the_macros_is_refused(self, mock_mealie):
|
||||
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 4})
|
||||
|
||||
# 13 g protein + 38 g fat + 15 g carbohydrate is ~455 kcal, not 150.
|
||||
with pytest.raises(ValueError, match="does not match the macros"):
|
||||
server.patch_recipe(
|
||||
"sas",
|
||||
{"nutrition": {
|
||||
"calories": 150,
|
||||
"proteinContent": 13,
|
||||
"fatContent": 38,
|
||||
"carbohydrateContent": 15,
|
||||
}},
|
||||
)
|
||||
assert sent == []
|
||||
|
||||
def test_nutrition_without_a_serving_count_is_refused(self, mock_mealie):
|
||||
# The TikTok import left recipeServings 0 and only a "4-6 personer" string,
|
||||
# so per-serving figures would have had nothing to divide by.
|
||||
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 0})
|
||||
|
||||
with pytest.raises(ValueError, match="recipeServings"):
|
||||
server.patch_recipe("sas", {"nutrition": {"calories": 459}})
|
||||
assert sent == []
|
||||
|
||||
def test_servings_patched_in_the_same_call_satisfy_the_requirement(self, mock_mealie):
|
||||
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 0})
|
||||
|
||||
server.patch_recipe("sas", {"recipeServings": 4, "nutrition": {"calories": 459}})
|
||||
|
||||
assert sent[0]["recipeServings"] == 4
|
||||
assert sent[0]["nutrition"] == {"calories": "459"}
|
||||
|
||||
def test_a_misspelt_field_names_the_right_one_instead_of_vanishing(self, mock_mealie):
|
||||
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 4})
|
||||
|
||||
with pytest.raises(ValueError, match="carbohydrateContent"):
|
||||
server.patch_recipe("sas", {"nutrition": {"carbs": 15}})
|
||||
assert sent == []
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -31,6 +31,9 @@ def complete_recipe(**overrides):
|
||||
"recipeCategory": [{"name": "Huvudrätter"}],
|
||||
"tags": [{"name": "Källa: Köket"}],
|
||||
"orgURL": "https://www.koket.se/kycklinggryta",
|
||||
"recipeServings": 4,
|
||||
"nutrition": {"calories": "520", "proteinContent": "31", "fatContent": "34",
|
||||
"carbohydrateContent": "18"},
|
||||
}
|
||||
recipe.update(overrides)
|
||||
return recipe
|
||||
@@ -130,6 +133,22 @@ class TestTaxonomy:
|
||||
assert report["complete"] is True
|
||||
|
||||
|
||||
class TestNutrition:
|
||||
def test_missing_nutrition_fails(self):
|
||||
report = verify_recipe(complete_recipe(nutrition={}), image_verified=True)
|
||||
assert status_of(report, "nutrition") == "fail"
|
||||
assert report["complete"] is False
|
||||
|
||||
def test_values_without_a_serving_count_fail(self):
|
||||
# Mealie shows nutrition per serving, so figures with recipeServings 0
|
||||
# are a numerator without a denominator — the state the TikTok import left.
|
||||
report = verify_recipe(complete_recipe(recipeServings=0), image_verified=True)
|
||||
assert status_of(report, "nutrition") == "fail"
|
||||
assert "recipeServings" in next(
|
||||
c["detail"] for c in report["checks"] if c["check"] == "nutrition"
|
||||
)
|
||||
|
||||
|
||||
class TestAttribution:
|
||||
def test_missing_source_warns_but_does_not_block(self):
|
||||
report = verify_recipe(
|
||||
|
||||
Reference in New Issue
Block a user