feat: recipe + staple API endpoints with tier gating
This commit is contained in:
parent
3943a8c99d
commit
ce61b5f422
5 changed files with 174 additions and 6 deletions
46
app/api/endpoints/recipes.py
Normal file
46
app/api/endpoints/recipes.py
Normal file
|
|
@ -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
|
||||
42
app/api/endpoints/staples.py
Normal file
42
app/api/endpoints/staples.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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"])
|
||||
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"])
|
||||
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
78
tests/api/test_recipes.py
Normal file
78
tests/api/test_recipes.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue