diff --git a/app/api/endpoints/recipes.py b/app/api/endpoints/recipes.py index dd190f7..0ccfbe8 100644 --- a/app/api/endpoints/recipes.py +++ b/app/api/endpoints/recipes.py @@ -9,7 +9,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query from app.cloud_session import CloudUser, get_session from app.db.store import Store -from app.models.schemas.recipe import RecipeRequest, RecipeResult +from app.models.schemas.recipe import ( + AssemblyTemplateOut, + BuildRequest, + RecipeRequest, + RecipeResult, + RecipeSuggestion, + RoleCandidatesResponse, +) +from app.services.recipe.assembly_recipes import ( + build_from_selection, + get_role_candidates, + get_templates_for_api, +) from app.services.recipe.browser_domains import ( DOMAINS, get_category_names, @@ -143,6 +155,80 @@ async def browse_recipes( return await asyncio.to_thread(_browse, session.db) +@router.get("/templates", response_model=list[AssemblyTemplateOut]) +async def list_assembly_templates() -> list[dict]: + """Return all 13 assembly templates with ordered role sequences. + + Cache-friendly: static data, no per-user state. + """ + return get_templates_for_api() + + +@router.get("/template-candidates", response_model=RoleCandidatesResponse) +async def get_template_role_candidates( + template_id: str = Query(..., description="Template slug, e.g. 'burrito_taco'"), + role: str = Query(..., description="Role display name, e.g. 'protein'"), + prior_picks: str = Query(default="", description="Comma-separated prior selections"), + session: CloudUser = Depends(get_session), +) -> dict: + """Return pantry-matched candidates for one wizard step.""" + def _get(db_path: Path) -> dict: + store = Store(db_path) + try: + items = store.list_inventory(status="available") + pantry_set = { + item["product_name"] + for item in items + if item.get("product_name") + } + pantry_list = list(pantry_set) + prior = [p.strip() for p in prior_picks.split(",") if p.strip()] + profile_index = store.get_element_profiles(pantry_list + prior) + return get_role_candidates( + template_slug=template_id, + role_display=role, + pantry_set=pantry_set, + prior_picks=prior, + profile_index=profile_index, + ) + finally: + store.close() + + return await asyncio.to_thread(_get, session.db) + + +@router.post("/build", response_model=RecipeSuggestion) +async def build_recipe( + req: BuildRequest, + session: CloudUser = Depends(get_session), +) -> RecipeSuggestion: + """Build a recipe from explicit role selections.""" + def _build(db_path: Path) -> RecipeSuggestion | None: + store = Store(db_path) + try: + items = store.list_inventory(status="available") + pantry_set = { + item["product_name"] + for item in items + if item.get("product_name") + } + return build_from_selection( + template_slug=req.template_id, + role_overrides=req.role_overrides, + pantry_set=pantry_set, + ) + finally: + store.close() + + result = await asyncio.to_thread(_build, session.db) + if result is None: + raise HTTPException( + status_code=404, + detail="Template not found or required ingredient missing.", + ) + return result + + @router.get("/{recipe_id}") async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict: def _get(db_path: Path, rid: int) -> dict | None: diff --git a/tests/api/test_recipe_build_endpoints.py b/tests/api/test_recipe_build_endpoints.py new file mode 100644 index 0000000..f40ed4c --- /dev/null +++ b/tests/api/test_recipe_build_endpoints.py @@ -0,0 +1,77 @@ +"""Tests for GET /templates, GET /template-candidates, POST /build endpoints.""" +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(tmp_path): + """FastAPI test client with a seeded in-memory DB.""" + import os + os.environ["KIWI_DB_PATH"] = str(tmp_path / "test.db") + os.environ["CLOUD_MODE"] = "false" + from app.main import app + from app.db.store import Store + store = Store(tmp_path / "test.db") + store.conn.execute( + "INSERT INTO products (name, barcode) VALUES (?,?)", ("chicken breast", None) + ) + store.conn.execute( + "INSERT INTO inventory_items (product_id, location, status) VALUES (1,'pantry','available')" + ) + store.conn.execute( + "INSERT INTO products (name, barcode) VALUES (?,?)", ("flour tortilla", None) + ) + store.conn.execute( + "INSERT INTO inventory_items (product_id, location, status) VALUES (2,'pantry','available')" + ) + store.conn.commit() + return TestClient(app) + + +def test_get_templates_returns_13(client): + resp = client.get("/api/v1/recipes/templates") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 13 + + +def test_get_templates_shape(client): + resp = client.get("/api/v1/recipes/templates") + t = next(t for t in resp.json() if t["id"] == "burrito_taco") + assert t["icon"] == "🌯" + assert len(t["role_sequence"]) >= 2 + assert t["role_sequence"][0]["required"] is True + + +def test_get_template_candidates_returns_shape(client): + resp = client.get( + "/api/v1/recipes/template-candidates", + params={"template_id": "burrito_taco", "role": "tortilla or wrap"} + ) + assert resp.status_code == 200 + data = resp.json() + assert "compatible" in data + assert "other" in data + assert "available_tags" in data + + +def test_post_build_returns_recipe(client): + resp = client.post("/api/v1/recipes/build", json={ + "template_id": "burrito_taco", + "role_overrides": { + "tortilla or wrap": "flour tortilla", + "protein": "chicken breast", + } + }) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == -1 + assert len(data["directions"]) > 0 + + +def test_post_build_unknown_template_returns_404(client): + resp = client.post("/api/v1/recipes/build", json={ + "template_id": "does_not_exist", + "role_overrides": {} + }) + assert resp.status_code == 404