kiwi/tests/services/recipe/test_recipe_scanner.py
pyr0ball 896b4e048c feat: recipe scanner — photo to structured recipe (kiwi#9)
New feature: photograph a recipe card, cookbook page, or handwritten
note and have it extracted into a structured, editable recipe.

Backend:
- POST /recipes/scan: accept 1-4 photos, run VLM extraction, return
  structured JSON for review (not auto-saved)
- POST /recipes/scan/save: persist a reviewed/edited recipe
- GET/DELETE /recipes/user: user-created recipe CRUD
- Vision backend priority: cf-orch -> local Qwen2.5-VL -> Anthropic BYOK
- 503 with clear config hint when no vision backend available
- Multi-photo support: facing pages (ingredients/directions) sent together
- Pantry cross-reference: marks which ingredients are already on hand
- migration 041: user_recipes table (title, servings, cook_time, steps,
  ingredients JSON, source, pantry_match_pct)
- Tier gate: recipe_scan -> paid, BYOK-unlockable

Frontend:
- "Scan" button in the Recipes tab bar (camera icon)
- RecipeScanModal: upload step (drag-drop + file picker, up to 4 photos,
  live previews), processing step (spinner), review/edit step (all
  fields inline-editable before save), pantry match badge, warning banner
  for low-confidence or incomplete scans

Tests: 35 new tests (23 unit + 12 API), 404 total passing
2026-04-27 08:23:01 -07:00

233 lines
9.4 KiB
Python

"""Unit tests for the recipe scanner service.
VLM calls are mocked — these tests cover JSON parsing, pantry cross-reference,
error handling, and result normalization. No GPU required.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from app.services.recipe.recipe_scanner import (
RecipeScanner,
ScannedIngredient,
ScannedRecipeResult,
_cross_reference_pantry,
_parse_scanner_json,
_normalize_ingredient_name,
)
# ── Fixtures ──────────────────────────────────────────────────────────────────
GOOD_JSON = {
"title": "Green Goddess Bowls",
"subtitle": "with Broccoli & Ranch Dressing",
"servings": "2",
"cook_time": "15 min",
"source_note": "Purple Carrot",
"ingredients": [
{"name": "brown rice", "qty": "1/2", "unit": "cup", "raw": "1/2 cup brown rice"},
{"name": "broccoli florets", "qty": "8", "unit": "oz", "raw": "8 oz broccoli florets"},
{"name": "avocado", "qty": "1", "unit": None, "raw": "1 avocado"},
{"name": "ranch dressing", "qty": "2", "unit": "tbsp", "raw": "2 tbsp Follow Your Heart Ranch"},
{"name": "pumpkin seeds", "qty": "1", "unit": "tbsp", "raw": "1 tbsp pumpkin seeds"},
],
"steps": [
"Cook rice according to package directions.",
"Steam broccoli for 5 minutes until tender.",
"Slice avocado. Assemble bowls and top with ranch.",
],
"notes": "Great leftover — keeps 3 days in the fridge.",
"confidence": "high",
"warnings": [],
}
def _fake_image_path(tmp_path: Path, name: str = "recipe.jpg") -> Path:
"""Create a tiny placeholder file so path-existence checks pass."""
p = tmp_path / name
p.write_bytes(b"\xff\xd8\xff") # minimal JPEG magic bytes
return p
# ── _normalize_ingredient_name ─────────────────────────────────────────────────
class TestNormalizeIngredientName:
def test_lowercases(self):
assert _normalize_ingredient_name("Brown Rice") == "brown rice"
def test_strips_whitespace(self):
assert _normalize_ingredient_name(" avocado ") == "avocado"
def test_removes_plural_s(self):
# For matching purposes only — "pumpkin seeds" stays as-is (stop at spaces)
assert _normalize_ingredient_name("avocados") == "avocados"
def test_passthrough(self):
assert _normalize_ingredient_name("ranch dressing") == "ranch dressing"
# ── _parse_scanner_json ───────────────────────────────────────────────────────
class TestParseScannerJson:
def test_parses_good_json(self):
result = _parse_scanner_json(json.dumps(GOOD_JSON))
assert result["title"] == "Green Goddess Bowls"
assert len(result["ingredients"]) == 5
def test_strips_markdown_fences(self):
wrapped = f"```json\n{json.dumps(GOOD_JSON)}\n```"
result = _parse_scanner_json(wrapped)
assert result["title"] == "Green Goddess Bowls"
def test_not_a_recipe_error(self):
with pytest.raises(ValueError, match="not_a_recipe"):
_parse_scanner_json(json.dumps({"error": "not_a_recipe"}))
def test_missing_title_returns_none_title(self):
data = dict(GOOD_JSON)
data.pop("title")
result = _parse_scanner_json(json.dumps(data))
assert result.get("title") is None
def test_malformed_json_raises(self):
with pytest.raises(ValueError, match="parse"):
_parse_scanner_json("this is not json at all")
def test_json_inside_prose(self):
"""Model sometimes adds leading text before the JSON object."""
text = f"Here is the extracted recipe:\n{json.dumps(GOOD_JSON)}"
result = _parse_scanner_json(text)
assert result["title"] == "Green Goddess Bowls"
# ── _cross_reference_pantry ───────────────────────────────────────────────────
class TestCrossReferencePantry:
PANTRY = ["brown rice", "pumpkin seeds", "olive oil", "broccoli"]
def test_marks_exact_match(self):
ingr = [
ScannedIngredient(name="brown rice", qty="1/2", unit="cup"),
ScannedIngredient(name="avocado", qty="1", unit=None),
]
result, pct = _cross_reference_pantry(ingr, self.PANTRY)
assert result[0].in_pantry is True
assert result[1].in_pantry is False
assert pct == 50
def test_partial_word_match(self):
"""'broccoli florets' should match pantry item 'broccoli'."""
ingr = [ScannedIngredient(name="broccoli florets", qty="8", unit="oz")]
result, pct = _cross_reference_pantry(ingr, self.PANTRY)
assert result[0].in_pantry is True
assert pct == 100
def test_empty_pantry_all_false(self):
ingr = [ScannedIngredient(name="broccoli", qty="1", unit=None)]
result, pct = _cross_reference_pantry(ingr, [])
assert result[0].in_pantry is False
assert pct == 0
def test_empty_ingredients_zero_pct(self):
_, pct = _cross_reference_pantry([], self.PANTRY)
assert pct == 0
def test_case_insensitive_match(self):
ingr = [ScannedIngredient(name="Brown Rice", qty="1", unit="cup")]
result, pct = _cross_reference_pantry(ingr, self.PANTRY)
assert result[0].in_pantry is True
# ── RecipeScanner ─────────────────────────────────────────────────────────────
class TestRecipeScanner:
def _make_scanner(self) -> RecipeScanner:
return RecipeScanner()
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
def test_scan_single_image_success(self, mock_call, tmp_path):
mock_call.return_value = json.dumps(GOOD_JSON)
img = _fake_image_path(tmp_path)
scanner = self._make_scanner()
result = scanner.scan([img], pantry_names=["brown rice", "avocado"])
assert isinstance(result, ScannedRecipeResult)
assert result.title == "Green Goddess Bowls"
assert result.servings == "2"
assert result.cook_time == "15 min"
assert len(result.ingredients) == 5
assert result.confidence == "high"
assert result.pantry_match_pct == 40 # 2 of 5 in pantry
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
def test_scan_multi_image(self, mock_call, tmp_path):
"""Two photos treated as one recipe — both passed to VLM."""
mock_call.return_value = json.dumps(GOOD_JSON)
img1 = _fake_image_path(tmp_path, "p1.jpg")
img2 = _fake_image_path(tmp_path, "p2.jpg")
scanner = self._make_scanner()
result = scanner.scan([img1, img2])
# Both images passed through
call_args = mock_call.call_args
assert len(call_args[0][0]) == 2 # image_paths list has 2 items
assert result.title == "Green Goddess Bowls"
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
def test_scan_not_a_recipe_raises(self, mock_call, tmp_path):
mock_call.return_value = json.dumps({"error": "not_a_recipe"})
img = _fake_image_path(tmp_path)
scanner = self._make_scanner()
with pytest.raises(ValueError, match="not_a_recipe"):
scanner.scan([img])
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
def test_warnings_propagated(self, mock_call, tmp_path):
data = dict(GOOD_JSON)
data["warnings"] = ["Directions appear to continue on another page not shown"]
mock_call.return_value = json.dumps(data)
img = _fake_image_path(tmp_path)
scanner = self._make_scanner()
result = scanner.scan([img])
assert len(result.warnings) == 1
assert "another page" in result.warnings[0]
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
def test_scan_no_pantry_names(self, mock_call, tmp_path):
mock_call.return_value = json.dumps(GOOD_JSON)
img = _fake_image_path(tmp_path)
scanner = self._make_scanner()
result = scanner.scan([img])
# No pantry passed — all in_pantry=False, pct=0
assert result.pantry_match_pct == 0
assert all(not i.in_pantry for i in result.ingredients)
def test_scan_too_many_images_raises(self, tmp_path):
imgs = [_fake_image_path(tmp_path, f"p{i}.jpg") for i in range(5)]
scanner = self._make_scanner()
with pytest.raises(ValueError, match="4 images"):
scanner.scan(imgs)
def test_scan_no_images_raises(self):
scanner = self._make_scanner()
with pytest.raises(ValueError, match="least one"):
scanner.scan([])
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
def test_backend_unavailable_raises(self, mock_call, tmp_path):
mock_call.side_effect = RuntimeError("No vision backend configured")
img = _fake_image_path(tmp_path)
scanner = self._make_scanner()
with pytest.raises(RuntimeError, match="No vision backend"):
scanner.scan([img])