kiwi/tests/api/test_recipe_scan.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

304 lines
11 KiB
Python

"""API tests for recipe scan endpoints (kiwi#9).
VLM calls are mocked at the service level -- no GPU or API key needed.
"""
from __future__ import annotations
import io
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.cloud_session import get_session
from app.db.session import get_store
client = TestClient(app)
_GOOD_SCAN_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"},
],
"steps": ["Cook rice.", "Steam broccoli.", "Assemble bowls."],
"notes": None,
"confidence": "high",
"warnings": [],
}
def _make_session(tier: str = "paid", has_byok: bool = False) -> MagicMock:
mock = MagicMock()
mock.tier = tier
mock.has_byok = has_byok
mock.db = ":memory:"
return mock
def _make_store() -> MagicMock:
mock = MagicMock()
mock.list_inventory.return_value = [
{"product_name": "brown rice"},
{"product_name": "avocado"},
]
mock.create_user_recipe.return_value = {
"id": 1,
"title": "Green Goddess Bowls",
"subtitle": "with Broccoli & Ranch Dressing",
"servings": "2",
"cook_time": "15 min",
"source_note": "Purple Carrot",
"ingredients": _GOOD_SCAN_JSON["ingredients"],
"steps": _GOOD_SCAN_JSON["steps"],
"notes": None,
"tags": [],
"source": "scan",
"pantry_match_pct": None,
"created_at": "2026-04-27T00:00:00",
}
mock.list_user_recipes.return_value = []
mock.get_user_recipe.return_value = None
mock.delete_user_recipe.return_value = False
return mock
def _fake_image() -> bytes:
return b"\xff\xd8\xff\xe0" + b"\x00" * 100 # minimal JPEG magic
@pytest.fixture(autouse=True)
def override_deps():
session_mock = _make_session()
store_mock = _make_store()
app.dependency_overrides[get_session] = lambda: session_mock
app.dependency_overrides[get_store] = lambda: store_mock
yield session_mock, store_mock
app.dependency_overrides.clear()
# ── POST /recipes/scan ─────────────────────────────────────────────────────────
def _make_scan_result(title: str = "Green Goddess Bowls"):
"""Create a fake ScannedRecipeResult for tests."""
from app.services.recipe.recipe_scanner import ScannedIngredient, ScannedRecipeResult
return ScannedRecipeResult(
title=title,
subtitle="with Broccoli & Ranch Dressing",
servings="2",
cook_time="15 min",
source_note="Purple Carrot",
ingredients=[
ScannedIngredient("brown rice", "1/2", "cup", in_pantry=True),
ScannedIngredient("broccoli florets", "8", "oz"),
ScannedIngredient("avocado", "1", None, in_pantry=True),
],
steps=["Cook rice.", "Steam broccoli.", "Assemble bowls."],
notes=None,
tags=[],
pantry_match_pct=67,
confidence="high",
warnings=[],
)
@pytest.fixture
def mock_scan_infra(tmp_path):
"""Patch file-saving and VLM calls so scan endpoint tests don't need disk or GPU."""
fake_path = tmp_path / "recipe.jpg"
fake_path.write_bytes(_fake_image())
async def _fake_save(upload_file):
return fake_path
with patch("app.api.endpoints.recipe_scan._save_upload_temp", side_effect=_fake_save):
with patch("app.api.endpoints.recipe_scan.asyncio.to_thread") as mock_thread:
yield mock_thread, fake_path
class TestScanEndpoint:
def test_scan_returns_200(self, override_deps, mock_scan_infra):
"""Happy path: paid tier, valid JPEG, VLM returns good JSON."""
_, store_mock = override_deps
mock_thread, _ = mock_scan_infra
scan_result = _make_scan_result()
call_count = 0
def side_effect(fn, *args, **kwargs):
nonlocal call_count
call_count += 1
return store_mock.list_inventory() if call_count == 1 else scan_result
mock_thread.side_effect = side_effect
resp = client.post(
"/api/v1/recipes/scan",
files=[("files", ("recipe.jpg", _fake_image(), "image/jpeg"))],
)
assert resp.status_code == 200
data = resp.json()
assert data["title"] == "Green Goddess Bowls"
assert data["confidence"] == "high"
assert data["pantry_match_pct"] == 67
assert len(data["ingredients"]) == 3
def test_scan_requires_paid_tier(self, override_deps):
"""Free tier without BYOK should get 403."""
session_mock, _ = override_deps
session_mock.tier = "free"
session_mock.has_byok = False
resp = client.post(
"/api/v1/recipes/scan",
files=[("files", ("recipe.jpg", _fake_image(), "image/jpeg"))],
)
assert resp.status_code == 403
def test_scan_byok_free_tier_allowed(self, override_deps, mock_scan_infra):
"""Free tier WITH BYOK should be allowed through the tier gate."""
session_mock, store_mock = override_deps
session_mock.tier = "free"
session_mock.has_byok = True
mock_thread, _ = mock_scan_infra
scan_result = _make_scan_result("Simple Bowl")
call_count = 0
def _side(fn, *a, **kw):
nonlocal call_count
call_count += 1
return store_mock.list_inventory() if call_count == 1 else scan_result
mock_thread.side_effect = _side
resp = client.post(
"/api/v1/recipes/scan",
files=[("files", ("recipe.jpg", _fake_image(), "image/jpeg"))],
)
assert resp.status_code == 200
def test_scan_no_files_rejected(self, override_deps):
"""Missing files field returns 422."""
resp = client.post("/api/v1/recipes/scan", files=[])
assert resp.status_code in (422, 400)
def test_scan_too_many_files(self, override_deps, mock_scan_infra):
"""More than 4 files should return 422."""
mock_thread, _ = mock_scan_infra
mock_thread.return_value = []
files = [("files", (f"p{i}.jpg", _fake_image(), "image/jpeg")) for i in range(5)]
resp = client.post("/api/v1/recipes/scan", files=files)
assert resp.status_code == 422
def test_scan_not_a_recipe_returns_422(self, override_deps, mock_scan_infra):
_, store_mock = override_deps
mock_thread, _ = mock_scan_infra
call_count = 0
def _side(fn, *a, **kw):
nonlocal call_count
call_count += 1
if call_count == 1:
return store_mock.list_inventory()
raise ValueError("not_a_recipe: image does not appear to contain a recipe")
mock_thread.side_effect = _side
resp = client.post(
"/api/v1/recipes/scan",
files=[("files", ("photo.jpg", _fake_image(), "image/jpeg"))],
)
assert resp.status_code == 422
assert "recipe" in resp.json()["detail"].lower()
def test_scan_backend_unavailable_returns_503(self, override_deps, mock_scan_infra):
_, store_mock = override_deps
mock_thread, _ = mock_scan_infra
call_count = 0
def _side(fn, *a, **kw):
nonlocal call_count
call_count += 1
if call_count == 1:
return store_mock.list_inventory()
raise RuntimeError("No vision backend configured")
mock_thread.side_effect = _side
resp = client.post(
"/api/v1/recipes/scan",
files=[("files", ("photo.jpg", _fake_image(), "image/jpeg"))],
)
assert resp.status_code == 503
# ── POST /recipes/scan/save ────────────────────────────────────────────────────
class TestSaveEndpoint:
def test_save_returns_201(self, override_deps):
_, store_mock = override_deps
store_mock.create_user_recipe.return_value = {
"id": 42,
"title": "Green Goddess Bowls",
"subtitle": None,
"servings": "2",
"cook_time": "15 min",
"source_note": None,
"ingredients": [{"name": "brown rice", "qty": "1", "unit": "cup", "raw": None, "in_pantry": False}],
"steps": ["Cook it."],
"notes": None,
"tags": [],
"source": "scan",
"pantry_match_pct": None,
"created_at": "2026-04-27T00:00:00",
}
payload = {
"title": "Green Goddess Bowls",
"servings": "2",
"cook_time": "15 min",
"ingredients": [{"name": "brown rice", "qty": "1", "unit": "cup"}],
"steps": ["Cook it."],
"source": "scan",
}
resp = client.post("/api/v1/recipes/scan/save", json=payload)
assert resp.status_code == 201
data = resp.json()
assert data["id"] == 42
assert data["title"] == "Green Goddess Bowls"
def test_save_missing_title_rejected(self, override_deps):
payload = {
"ingredients": [{"name": "eggs", "qty": "2"}],
"steps": ["Scramble."],
}
resp = client.post("/api/v1/recipes/scan/save", json=payload)
assert resp.status_code == 422
# ── GET /recipes/user ──────────────────────────────────────────────────────────
class TestUserRecipeEndpoints:
def test_list_empty(self, override_deps):
_, store_mock = override_deps
store_mock.list_user_recipes.return_value = []
resp = client.get("/api/v1/recipes/user")
assert resp.status_code == 200
assert resp.json() == []
def test_get_not_found(self, override_deps):
_, store_mock = override_deps
store_mock.get_user_recipe.return_value = None
resp = client.get("/api/v1/recipes/user/999")
assert resp.status_code == 404
def test_delete_not_found(self, override_deps):
_, store_mock = override_deps
store_mock.delete_user_recipe.return_value = False
resp = client.delete("/api/v1/recipes/user/999")
assert resp.status_code == 404