Public Access
feat: feedback-driven recipe discovery (auto-ingest via Spoonacular)
This commit is contained in:
@@ -46,15 +46,16 @@ def _resolve_test_dsn() -> str | None:
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL")
|
||||
if not dsn:
|
||||
return None
|
||||
if not dsn.startswith(("postgresql://", "postgresql+psycopg2://")):
|
||||
if not dsn.startswith(("postgresql://", "postgresql+psycopg2://", "postgresql+pg8000://")):
|
||||
return None
|
||||
return dsn
|
||||
|
||||
|
||||
def _postgres_reachable(dsn: str) -> bool:
|
||||
try:
|
||||
driver = "postgresql+pg8000://" if "pg8000" in dsn else "postgresql+psycopg2://"
|
||||
eng = create_engine(
|
||||
dsn.replace("postgresql://", "postgresql+psycopg2://"),
|
||||
dsn.replace("postgresql://", driver).replace("postgresql+psycopg2://", driver),
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
with eng.connect() as conn:
|
||||
@@ -130,8 +131,9 @@ def _engine(_schema):
|
||||
if not _PG_AVAILABLE:
|
||||
yield None
|
||||
return
|
||||
driver = "postgresql+pg8000://" if "pg8000" in _DSN else "postgresql+psycopg2://"
|
||||
eng = create_engine(
|
||||
_DSN.replace("postgresql://", "postgresql+psycopg2://"),
|
||||
_DSN.replace("postgresql://", driver).replace("postgresql+psycopg2://", driver),
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
yield eng
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Tests for app.services.feedback_analyzer."""
|
||||
from datetime import date, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import (
|
||||
DenialReason,
|
||||
FamilyProfile,
|
||||
FamilyMember,
|
||||
FamilyMemberRole,
|
||||
Feedback,
|
||||
MealPlan,
|
||||
MealPlanItem,
|
||||
MealPlanItemStatus,
|
||||
MealType,
|
||||
NeverSuggest,
|
||||
Recipe,
|
||||
WeeklyRun,
|
||||
)
|
||||
from app.services.feedback_analyzer import FeedbackAnalyzer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_family(db):
|
||||
def _make():
|
||||
fp = FamilyProfile(
|
||||
id=uuid4(),
|
||||
name="TestFamily",
|
||||
household_size=2,
|
||||
adult_count=2,
|
||||
child_count=0,
|
||||
pending_approval_policy="approve",
|
||||
)
|
||||
db.add(fp)
|
||||
db.flush()
|
||||
return fp
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_member(db, make_family):
|
||||
def _make(family=None):
|
||||
fp = family or make_family()
|
||||
m = FamilyMember(
|
||||
id=uuid4(),
|
||||
family_profile_id=fp.id,
|
||||
name="Alice",
|
||||
role=FamilyMemberRole.ADULT,
|
||||
)
|
||||
db.add(m)
|
||||
db.flush()
|
||||
return m
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_recipe(db):
|
||||
def _make(**kw):
|
||||
r = Recipe(
|
||||
id=uuid4(),
|
||||
name=kw.get("name", "Test Recipe"),
|
||||
servings=kw.get("servings", 4),
|
||||
ingredients=[{"name": "ing", "qty": 1, "unit": "cup"}],
|
||||
instructions=["cook"],
|
||||
cuisine_tags=kw.get("cuisine_tags", []),
|
||||
protein_type=kw.get("protein_type", None),
|
||||
)
|
||||
db.add(r)
|
||||
db.flush()
|
||||
return r
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_meal_plan(db, make_family):
|
||||
def _make(family=None, week_start=None):
|
||||
week = week_start or date.today()
|
||||
mp = MealPlan(
|
||||
id=uuid4(),
|
||||
family_profile_id=(family or make_family()).id,
|
||||
week_start_date=week,
|
||||
)
|
||||
db.add(mp)
|
||||
db.flush()
|
||||
return mp
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_item(db, make_meal_plan, make_recipe):
|
||||
def _make(meal_plan=None, recipe=None, status=MealPlanItemStatus.pending, day_of_week=1):
|
||||
mp = meal_plan or make_meal_plan()
|
||||
r = recipe or make_recipe()
|
||||
item = MealPlanItem(
|
||||
id=uuid4(),
|
||||
meal_plan_id=mp.id,
|
||||
recipe_id=r.id,
|
||||
day_of_week=day_of_week,
|
||||
meal_type=MealType.DINNER,
|
||||
approval_status=status,
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
return item
|
||||
return _make
|
||||
|
||||
|
||||
class TestFeedbackAnalyzer:
|
||||
def test_insufficient_feedback(self, db, make_family):
|
||||
family = make_family()
|
||||
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
||||
today = date.today()
|
||||
result = analyzer.analyze(db, family.id, today=today)
|
||||
assert result.total_feedback_count == 0
|
||||
assert result.confidence == 0.0
|
||||
assert result.discovery_queries == []
|
||||
|
||||
def test_positive_cuisine_signal(self, db, make_family, make_member, make_recipe, make_meal_plan, make_item):
|
||||
family = make_family()
|
||||
member = make_member(family=family)
|
||||
recipe = make_recipe(cuisine_tags=["mexican"], protein_type="chicken")
|
||||
mp = make_meal_plan(family=family)
|
||||
item = make_item(meal_plan=mp, recipe=recipe)
|
||||
|
||||
# 3 feedbacks, avg rating 4 (>= threshold, >= 2 samples)
|
||||
for _ in range(3):
|
||||
f = Feedback(
|
||||
id=uuid4(),
|
||||
family_profile_id=family.id,
|
||||
meal_plan_item_id=item.id,
|
||||
rating=4,
|
||||
)
|
||||
db.add(f)
|
||||
db.flush()
|
||||
|
||||
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
||||
result = analyzer.analyze(db, family.id)
|
||||
assert result.total_feedback_count == 3
|
||||
assert result.confidence == 1.0
|
||||
|
||||
pos = result.positive_signals
|
||||
assert len(pos) == 2 # cuisine + protein
|
||||
assert any(s.type == "prefer_cuisine" and s.value == "mexican" for s in pos)
|
||||
assert any(s.type == "prefer_protein" and s.value == "chicken" for s in pos)
|
||||
|
||||
def test_never_suggest_blocks_recipe(self, db, make_family, make_member, make_recipe, make_meal_plan, make_item):
|
||||
family = make_family()
|
||||
member = make_member(family=family)
|
||||
recipe = make_recipe(cuisine_tags=["indian"], protein_type="lamb")
|
||||
mp = make_meal_plan(family=family)
|
||||
item = make_item(meal_plan=mp, recipe=recipe)
|
||||
|
||||
# Deny with never-suggest
|
||||
f = Feedback(
|
||||
id=uuid4(),
|
||||
family_profile_id=family.id,
|
||||
meal_plan_item_id=item.id,
|
||||
rating=1,
|
||||
never_suggest=True,
|
||||
denial_reason=DenialReason.DISLIKED_INGREDIENT,
|
||||
)
|
||||
db.add(f)
|
||||
db.flush()
|
||||
|
||||
ns = NeverSuggest(
|
||||
id=uuid4(),
|
||||
family_profile_id=family.id,
|
||||
recipe_id=recipe.id,
|
||||
)
|
||||
db.add(ns)
|
||||
db.flush()
|
||||
|
||||
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
||||
result = analyzer.analyze(db, family.id)
|
||||
pos = result.positive_signals
|
||||
# 1 feedback < min, therefore no positive signals
|
||||
assert len(pos) == 0
|
||||
|
||||
def test_denial_reason_aggregated(self, db, make_family, make_member, make_recipe, make_meal_plan, make_item):
|
||||
family = make_family()
|
||||
member = make_member(family=family)
|
||||
recipe = make_recipe()
|
||||
mp = make_meal_plan(family=family)
|
||||
item = make_item(meal_plan=mp, recipe=recipe)
|
||||
|
||||
for _ in range(3):
|
||||
f = Feedback(
|
||||
id=uuid4(),
|
||||
family_profile_id=family.id,
|
||||
meal_plan_item_id=item.id,
|
||||
rating=2,
|
||||
denial_reason=DenialReason.TOO_EXPENSIVE,
|
||||
)
|
||||
db.add(f)
|
||||
db.flush()
|
||||
|
||||
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
||||
result = analyzer.analyze(db, family.id)
|
||||
negatives = result.negative_signals
|
||||
assert any(n.type == "denial_too_expensive" for n in negatives)
|
||||
assert negatives[0].count == 3
|
||||
|
||||
def test_discovery_queries_capped(self, db, make_family, make_member, make_recipe, make_meal_plan, make_item):
|
||||
family = make_family()
|
||||
member = make_member(family=family)
|
||||
cuisines = ["mexican", "italian", "chinese"]
|
||||
proteins = ["chicken", "beef", "shrimp"]
|
||||
for i, (c, p) in enumerate(zip(cuisines, proteins)):
|
||||
recipe = make_recipe(name=f"R{i}", cuisine_tags=[c], protein_type=p)
|
||||
mp = make_meal_plan(family=family, week_start=date.today() - timedelta(weeks=i))
|
||||
item = make_item(meal_plan=mp, recipe=recipe)
|
||||
for _ in range(2):
|
||||
db.add(Feedback(
|
||||
id=uuid4(),
|
||||
family_profile_id=family.id,
|
||||
meal_plan_item_id=item.id,
|
||||
rating=5,
|
||||
))
|
||||
db.flush()
|
||||
|
||||
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
||||
result = analyzer.analyze(db, family.id)
|
||||
queries = result.discovery_queries
|
||||
assert len(queries) <= 5
|
||||
# At least one cuisine+protein cross query
|
||||
assert any(" " in q for q in queries)
|
||||
|
||||
def test_top_rated_sorted(self, db, make_family, make_member, make_recipe, make_meal_plan, make_item):
|
||||
family = make_family()
|
||||
member = make_member(family=family)
|
||||
r1 = make_recipe(name="Awesome Dish")
|
||||
r2 = make_recipe(name="Meh Dish")
|
||||
mp = make_meal_plan(family=family)
|
||||
item1 = make_item(meal_plan=mp, recipe=r1)
|
||||
item2 = make_item(meal_plan=mp, recipe=r2, day_of_week=2)
|
||||
|
||||
db.add(Feedback(id=uuid4(), family_profile_id=family.id, meal_plan_item_id=item1.id, rating=5))
|
||||
db.add(Feedback(id=uuid4(), family_profile_id=family.id, meal_plan_item_id=item1.id, rating=5))
|
||||
db.add(Feedback(id=uuid4(), family_profile_id=family.id, meal_plan_item_id=item2.id, rating=3))
|
||||
db.flush()
|
||||
|
||||
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
||||
result = analyzer.analyze(db, family.id)
|
||||
assert result.top_rated_recipe_names[0] == "Awesome Dish"
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"""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="<b>Delicious</b> 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"
|
||||
Reference in New Issue
Block a user