feat: add GET /templates, GET /template-candidates, POST /build endpoints
Wires the three Build Your Own API routes into the recipes router,
registered before the catch-all /{recipe_id} route to avoid shadowing.
Adds 5 endpoint tests covering template list count/shape, candidate
response structure, successful recipe build, and 404 on unknown template.
This commit is contained in:
parent
c02e538cb2
commit
8c4965123f
2 changed files with 164 additions and 1 deletions
|
|
@ -9,7 +9,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
from app.db.store import Store
|
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 (
|
from app.services.recipe.browser_domains import (
|
||||||
DOMAINS,
|
DOMAINS,
|
||||||
get_category_names,
|
get_category_names,
|
||||||
|
|
@ -143,6 +155,80 @@ async def browse_recipes(
|
||||||
return await asyncio.to_thread(_browse, session.db)
|
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}")
|
@router.get("/{recipe_id}")
|
||||||
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
||||||
def _get(db_path: Path, rid: int) -> dict | None:
|
def _get(db_path: Path, rid: int) -> dict | None:
|
||||||
|
|
|
||||||
77
tests/api/test_recipe_build_endpoints.py
Normal file
77
tests/api/test_recipe_build_endpoints.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue