diff --git a/app/api/endpoints/recipes.py b/app/api/endpoints/recipes.py new file mode 100644 index 0000000..d74cbe8 --- /dev/null +++ b/app/api/endpoints/recipes.py @@ -0,0 +1,46 @@ +"""Recipe suggestion endpoints.""" +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, Depends, HTTPException + +from app.cloud_session import CloudUser, get_session +from app.db.session import get_store +from app.db.store import Store +from app.models.schemas.recipe import RecipeRequest, RecipeResult +from app.services.recipe.recipe_engine import RecipeEngine +from app.tiers import can_use + +router = APIRouter() + + +@router.post("/suggest", response_model=RecipeResult) +async def suggest_recipes( + req: RecipeRequest, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> RecipeResult: + if req.level == 4 and not req.wildcard_confirmed: + raise HTTPException( + status_code=400, + detail="Level 4 (Wildcard) requires wildcard_confirmed=true.", + ) + if req.level in (3, 4) and not can_use("recipe_suggestions", session.tier, session.has_byok): + raise HTTPException( + status_code=403, + detail="LLM recipe levels require Paid tier or a configured LLM backend.", + ) + if req.style_id and not can_use("style_picker", session.tier): + raise HTTPException(status_code=403, detail="Style picker requires Paid tier.") + req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok}) + engine = RecipeEngine(store) + return await asyncio.to_thread(engine.suggest, req) + + +@router.get("/{recipe_id}") +async def get_recipe(recipe_id: int, store: Store = Depends(get_store)) -> dict: + recipe = await asyncio.to_thread(store.get_recipe, recipe_id) + if not recipe: + raise HTTPException(status_code=404, detail="Recipe not found.") + return recipe diff --git a/app/api/endpoints/staples.py b/app/api/endpoints/staples.py new file mode 100644 index 0000000..8660da5 --- /dev/null +++ b/app/api/endpoints/staples.py @@ -0,0 +1,42 @@ +"""Staple library endpoints.""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from app.services.recipe.staple_library import StapleLibrary + +router = APIRouter() +_lib = StapleLibrary() + + +@router.get("/") +async def list_staples(dietary: str | None = None) -> list[dict]: + staples = _lib.filter_by_dietary(dietary) if dietary else _lib.list_all() + return [ + { + "slug": s.slug, + "name": s.name, + "description": s.description, + "dietary_labels": s.dietary_labels, + "yield_formats": list(s.yield_formats.keys()), + } + for s in staples + ] + + +@router.get("/{slug}") +async def get_staple(slug: str) -> dict: + staple = _lib.get(slug) + if not staple: + raise HTTPException(status_code=404, detail=f"Staple '{slug}' not found.") + return { + "slug": staple.slug, + "name": staple.name, + "description": staple.description, + "dietary_labels": staple.dietary_labels, + "base_ingredients": staple.base_ingredients, + "base_method": staple.base_method, + "base_time_minutes": staple.base_time_minutes, + "yield_formats": staple.yield_formats, + "compatible_styles": staple.compatible_styles, + } diff --git a/app/api/routes.py b/app/api/routes.py index 2405e56..c07a15c 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,10 +1,12 @@ from fastapi import APIRouter -from app.api.endpoints import health, receipts, export, inventory, ocr +from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, staples api_router = APIRouter() -api_router.include_router(health.router, prefix="/health", tags=["health"]) -api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"]) -api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"]) # OCR endpoints under /receipts -api_router.include_router(export.router, tags=["export"]) # No prefix, uses /export in the router -api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"]) \ No newline at end of file +api_router.include_router(health.router, prefix="/health", tags=["health"]) +api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"]) +api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"]) +api_router.include_router(export.router, tags=["export"]) +api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"]) +api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"]) +api_router.include_router(staples.router, prefix="/staples", tags=["staples"]) \ No newline at end of file diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/test_recipes.py b/tests/api/test_recipes.py new file mode 100644 index 0000000..e3a06a0 --- /dev/null +++ b/tests/api/test_recipes.py @@ -0,0 +1,78 @@ +import pytest +from fastapi.testclient import TestClient +from unittest.mock import MagicMock + +from app.main import app +from app.cloud_session import get_session +from app.db.session import get_store + +client = TestClient(app) + + +def _make_session(tier: str = "free", has_byok: bool = False) -> MagicMock: + mock = MagicMock() + mock.tier = tier + mock.has_byok = has_byok + return mock + + +def _make_store() -> MagicMock: + mock = MagicMock() + mock.search_recipes_by_ingredients.return_value = [ + { + "id": 1, + "title": "Butter Pasta", + "ingredient_names": ["butter", "pasta"], + "element_coverage": {"Richness": 0.5}, + "match_count": 2, + "directions": ["mix and heat"], + } + ] + mock.check_and_increment_rate_limit.return_value = (True, 1) + return mock + + +@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() + + +def test_suggest_returns_200(): + resp = client.post("/api/v1/recipes/suggest", json={ + "pantry_items": ["butter", "pasta"], + "level": 1, + "constraints": [], + }) + assert resp.status_code == 200 + data = resp.json() + assert "suggestions" in data + assert "element_gaps" in data + assert "grocery_list" in data + assert "grocery_links" in data + + +def test_suggest_level4_requires_wildcard_confirmed(): + resp = client.post("/api/v1/recipes/suggest", json={ + "pantry_items": ["butter"], + "level": 4, + "constraints": [], + "wildcard_confirmed": False, + }) + assert resp.status_code == 400 + + +def test_suggest_level3_requires_paid_tier(override_deps): + session_mock, _ = override_deps + session_mock.tier = "free" + session_mock.has_byok = False + resp = client.post("/api/v1/recipes/suggest", json={ + "pantry_items": ["butter"], + "level": 3, + "constraints": [], + }) + assert resp.status_code == 403