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
304 lines
11 KiB
Python
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
|