"""Tests for app.services.recipe_discovery and app.services.recipe_ingestion. These are unit tests using mocked HTTP responses; no network calls. """ from __future__ import annotations import uuid from datetime import date from unittest.mock import MagicMock, patch import pytest pytestmark = pytest.mark.requires_postgres class MockResponse: """Minimal stand-in for requests.Response.""" def __init__(self, json_data: dict | None = None, status_code: int = 200): self._json = json_data or {} self.status_code = status_code def raise_for_status(self): if self.status_code >= 400: raise Exception(f"HTTP {self.status_code}") def json(self): return self._json class TestRecipeDiscoveryService: @patch("app.services.recipe_discovery.requests.get") def test_disabled_without_api_key(self, mock_get): from app.services.recipe_discovery import RecipeDiscoveryService svc = RecipeDiscoveryService() svc.enabled = False recipes = svc.discover(["chicken"]) assert recipes == [] mock_get.assert_not_called() @patch("app.services.recipe_discovery.requests.get") def test_quota_stop(self, mock_get): from app.services.recipe_discovery import RecipeDiscoveryService svc = RecipeDiscoveryService() svc.enabled = True # Simulate quota already consumed svc._points_used = 140 recipes = svc.discover(["query1", "query2"]) assert recipes == [] mock_get.assert_not_called() @patch("app.services.recipe_discovery.requests.get") def test_single_result_normalization(self, mock_get): from app.services.recipe_discovery import RecipeDiscoveryService, ExternalRecipe svc = RecipeDiscoveryService() svc.enabled = True search_resp = { "results": [ { "id": 123, "title": "Spicy Thai Basil Chicken", "image": "http://img/1.jpg", "cuisines": ["Thai"], "diets": ["gluten free"], "servings": 4, } ], "totalResults": 1, } info_resp = { "id": 123, "title": "Spicy Thai Basil Chicken", "extendedIngredients": [ {"amount": 1.5, "unit": "lb", "name": "chicken breast"}, {"amount": 2, "unit": "tbsp", "name": "basil"}, ], "analyzedInstructions": [{"steps": [{"step": "Cook chicken"}, {"step": "Add basil"}]}], "preparationMinutes": 10, "cookingMinutes": 20, "readyInMinutes": 30, "servings": 4, "sourceUrl": "http://example.com/recipe", "image": "http://img/1.jpg", } def _make_resp(*a, **kw): url = a[0] if a else "" if "complexSearch" in url: return MockResponse(search_resp) if "information" in url: return MockResponse(info_resp) return MockResponse({}) mock_get.side_effect = _make_resp recipes = svc.discover(["thai chicken"]) assert len(recipes) == 1 r = recipes[0] assert isinstance(r, ExternalRecipe) assert r.name == "Spicy Thai Basil Chicken" assert r.external_source == "spoonacular" assert r.external_id == "123" assert r.cuisine_tags == ["thai"] assert r.dietary_tags == ["gluten free"] assert r.servings == 4 assert len(r.ingredients) == 2 assert r.ingredients[0] == {"name": "chicken breast", "qty": 1.5, "unit": "lb"} assert r.instructions == ["Cook chicken", "Add basil"] assert r.prep_time_minutes == 10 assert r.cook_time_minutes == 20 assert r.source_url == "http://example.com/recipe" @patch("app.services.recipe_discovery.requests.get") def test_deduplication_across_queries(self, mock_get): from app.services.recipe_discovery import RecipeDiscoveryService svc = RecipeDiscoveryService() svc.enabled = True resp = { "results": [ {"id": 1, "title": "A", "image": "", "cuisines": [], "diets": [], "servings": 2} ], "totalResults": 1, } def _make_resp(*a, **kw): return MockResponse(resp) mock_get.side_effect = _make_resp recipes = svc.discover(["q1", "q2"]) # Same recipe ID should only appear once even though both queries returned it assert len(recipes) == 1 @patch("app.services.recipe_discovery.requests.get") def test_search_failure_graceful(self, mock_get): from app.services.recipe_discovery import RecipeDiscoveryService import requests svc = RecipeDiscoveryService() svc.enabled = True mock_get.side_effect = requests.ConnectionError("network error") recipes = svc.discover(["chicken"]) assert recipes == [] class TestRecipeIngestionService: def test_ingest_skips_duplicate_external(self, db): from app.services.recipe_ingestion import RecipeIngestionService from app.services.recipe_discovery import ExternalRecipe from app.models import Recipe, FamilyProfile svc = RecipeIngestionService() family = FamilyProfile( id=uuid.uuid4(), name="IngestFamily", household_size=2, adult_count=2, child_count=0, pending_approval_policy="approve", ) db.add(family) db.flush() # Seed existing row with same external_source+external_id existing = Recipe( id=uuid.uuid4(), family_profile_id=family.id, name="Already There", servings=4, ingredients=[], instructions=["cook"], external_source="spoonacular", external_id="99", ) db.add(existing) db.flush() ext = ExternalRecipe( name="Already There", external_source="spoonacular", external_id="99", image_url=None, description=None, prep_time_minutes=None, cook_time_minutes=None, servings=2, cuisine_tags=[], dietary_tags=[], protein_type=None, calories_per_serving=None, ingredients=[], instructions=["cook"], source_url=None, ) added = svc.ingest(db, family.id, [ext], {"discovery_queries": [], "positive_signals": []}) assert added == 0 def test_ingest_fuzzy_duplicate_skips(self, db): from app.services.recipe_ingestion import RecipeIngestionService from app.services.recipe_discovery import ExternalRecipe from app.models import Recipe, FamilyProfile svc = RecipeIngestionService() family = FamilyProfile( id=uuid.uuid4(), name="FuzzyFamily", household_size=2, adult_count=2, child_count=0, pending_approval_policy="approve", ) db.add(family) db.flush() existing = Recipe( id=uuid.uuid4(), family_profile_id=family.id, name="Grilled Salmon with Lemon Butter Sauce", servings=4, ingredients=[], instructions=["cook"], ) db.add(existing) db.flush() ext = ExternalRecipe( name="Grilled Salmon with Lemon Butter Sauce and Herbs", external_source="spoonacular", external_id="42", image_url=None, description=None, prep_time_minutes=None, cook_time_minutes=None, servings=2, cuisine_tags=[], dietary_tags=[], protein_type=None, calories_per_serving=None, ingredients=[], instructions=["cook"], source_url=None, ) added = svc.ingest(db, family.id, [ext], {"discovery_queries": [], "positive_signals": []}) assert added == 0 def test_ingest_creates_recipe_and_ingredient(self, db): from app.services.recipe_ingestion import RecipeIngestionService from app.services.recipe_discovery import ExternalRecipe from app.models import Recipe, FamilyProfile, Ingredient svc = RecipeIngestionService() family = FamilyProfile( id=uuid.uuid4(), name="CreateFamily", household_size=2, adult_count=2, child_count=0, pending_approval_policy="approve", ) db.add(family) db.flush() ext = ExternalRecipe( name="Lemon Herb Salmon", external_source="spoonacular", external_id="77", image_url="http://img/salmon.jpg", description="Delicious salmon recipe", prep_time_minutes=10, cook_time_minutes=15, servings=2, cuisine_tags=["mediterranean"], dietary_tags=["pescatarian"], protein_type="fish", calories_per_serving=350, ingredients=[ {"name": "salmon fillet", "qty": 2.0, "unit": "lb"}, ], instructions=["Preheat oven", "Bake till flaky"], source_url="http://example.com/77", ) analysis = { "discovery_queries": ["mediterranean fish"], "positive_signals": [{"type": "prefer_cuisine", "value": "mediterranean"}], } added = svc.ingest(db, family.id, [ext], analysis) assert added == 1 recipes = db.query(Recipe).filter(Recipe.external_id == "77").all() assert len(recipes) == 1 r = recipes[0] assert r.name == "Lemon Herb Salmon" assert r.external_source == "spoonacular" assert r.external_id == "77" assert r.protein_type == "fish" assert r.is_manually_added is False assert r.discovery_reason is not None assert "mediterranean fish" in r.discovery_reason assert r.cuisine_tags == ["mediterranean"] assert r.dietary_tags == ["pescatarian"] assert r.calories_per_serving == 350 assert r.ingredients[0]["name"] == "Salmon Fillet" assert r.instructions == ["Preheat oven", "Bake till flaky"] # Ingredient was created ings = db.query(Ingredient).filter(Ingredient.name_lower == "salmon fillet").all() assert len(ings) == 1 assert ings[0].name == "Salmon Fillet" def test_ingest_maps_to_existing_ingredient(self, db): from app.services.recipe_ingestion import RecipeIngestionService from app.services.recipe_discovery import ExternalRecipe from app.models import Recipe, FamilyProfile, Ingredient svc = RecipeIngestionService() family = FamilyProfile( id=uuid.uuid4(), name="MapFamily", household_size=2, adult_count=2, child_count=0, pending_approval_policy="approve", ) db.add(family) existing_ing = Ingredient( id=uuid.uuid4(), name="Firm Tofu", name_lower="firm tofu", aliases=["tofu"], ) db.add(existing_ing) db.flush() ext = ExternalRecipe( name="Mapo Tofu", external_source="spoonacular", external_id="88", image_url=None, description=None, prep_time_minutes=None, cook_time_minutes=None, servings=2, cuisine_tags=[], dietary_tags=[], protein_type=None, calories_per_serving=None, ingredients=[ {"name": "Firm Tofu", "qty": 1, "unit": "lb"}, ], instructions=["fry"], source_url=None, ) added = svc.ingest(db, family.id, [ext], {"discovery_queries": [], "positive_signals": []}) assert added == 1 recipes = db.query(Recipe).filter(Recipe.external_id == "88").all() assert recipes[0].ingredients[0]["ingredient_id"] == str(existing_ing.id) assert recipes[0].ingredients[0]["name"] == "Firm Tofu"