# Sprint 12 — F8 Spoonacular search — verification **Status (2026-06-05):** ✅ Code complete. `npm run build` green. Awaiting user deploy. ## Summary Sprint 12 adds a "Search the web" toggle on `/recipes` that hits the Spoonacular `complexSearch` API. Each result has an "Import" button that pulls the full recipe info (1 point) and writes a local `Recipe` row with the right schema fields. Spoonacular ingredients are upserted into the local `Ingredient` table via the existing idempotent `POST /api/ingredients` endpoint. **No pre-existing WIP files were touched.** Sprint 12 creates a new `backend/app/api/recipe_search.py` router (separate from the WIP `recipes.py`) and adds 2 Pydantic models to `backend/app/schemas/__init__.py` (the canonical location for the registered endpoints). The pre-existing WIP `recipes.py` is registered in `main.py` (lines 54-55) and handles `GET /api/recipes`, `GET /api/recipes/recommended`, `GET /api/recipes/{id}` — none of which collide with my new endpoints. **Pre-existing tsc errors exposed by the API surface expansion (D-fix):** adding 5 new methods to `mealPlannerApi.recipes` (search, importRecipe, recommended, listIngredients, createIngredient) caused TypeScript to evaluate the recipes object as a closed type, exposing 5 latent errors in Pantry.tsx / MealDetail.tsx / Recommended.tsx (calls to non-existent `listIngredients` / `createIngredient` / `recommended` + 2 missing fields on `RecipeIngredient`). User decision: add stub methods + fix the `RecipeIngredient` type. 7 lines of fixes; no pre-existing WIP touched. ## Files changed ### Backend - **NEW** `backend/app/api/recipe_search.py` (~270 lines) — 2 endpoints + module-level quota counter + thread-safe lock - **MODIFIED** `backend/app/config.py` — added `SPOONACULAR_API_KEY: Optional[str] = None` to `Settings` (was previously read via `getattr` since `extra="ignore"`) - **MODIFIED** `backend/app/schemas/__init__.py` — added `RecipeSearchHit` + `RecipeImportRequest` Pydantic models - **MODIFIED** `backend/app/main.py` — registered `recipe_search_api.router` at `/api/recipes` ### Frontend - **MODIFIED** `frontend/src/api/index.ts` — added 5 methods to `recipes`: `search`, `importRecipe`, `recommended`, `listIngredients`, `createIngredient` (the last 3 are stubs for pre-existing call sites) - **MODIFIED** `frontend/src/pages/Recipes.tsx` — added `searchWeb` toggle state + `importedExternalIds` set + `webHits` query + `importMutation` + the toggle button (with `aria-pressed`) + the web-search panel - **MODIFIED** `frontend/src/types/index.ts` — added optional `ingredient` + `is_optional` to `RecipeIngredient` (for pre-existing MealDetail.tsx call sites) ## Build verification ```text vite v5.4.21 building for production... transforming... ✓ 1897 modules transformed. rendering chunks... computing gzip size... dist/index.html 0.54 kB │ gzip: 0.32 kB dist/assets/index-BEAXfX_U.css 42.06 kB │ gzip: 7.26 kB dist/assets/index-w9IMb94J.js 500.28 kB │ gzip: 153.46 kB ✓ built in 2.63s ``` - `tsc` 0 errors, `vite` 0 errors. - Bundle: 496.48 → 500.28 kB (+3.8 kB for the web-search panel + the import mutation). ## Backend verification (manual, post-deploy) ```bash # 1) Search Spoonacular (summary only, 1.1 points) curl -sS 'http://100.108.208.56:8082/api/recipes/search?q=chicken&limit=5' \ -H 'Cookie: mealplanner_session=...' | jq '.[0] | {name, external_id, cuisine_tags}' # Expected: { "name": "Chicken ...", "external_id": "...", "cuisine_tags": [...] } # 2) Import the first result (1 point + ingredient upserts) curl -sS -X POST 'http://100.108.208.56:8082/api/recipes/import' \ -H 'Content-Type: application/json' \ -H 'Cookie: mealplanner_session=...' \ -d '{"external_id": ""}' | jq # Expected: { "id": "", "name": "...", "external_id": "...", # "ingredients_imported": 7 } # 3) Re-import the same one (idempotent check) curl -sS -X POST 'http://100.108.208.56:8082/api/recipes/import' \ -H 'Content-Type: application/json' \ -H 'Cookie: mealplanner_session=...' \ -d '{"external_id": ""}' -i | head -1 # Expected: HTTP/1.1 409 Conflict ``` ## Browser smoke (4 steps) Run on `http://100.108.208.56:8082/recipes`. 1. **Land on `/recipes`.** Confirm: the "Search the web" toggle is OFF (secondary variant). The local list shows. No web-search panel. 2. **Click the toggle.** Confirm: button flips to primary variant. The web-search panel renders with the header "Search the web — Spoonacular" and the prompt "Type at least 2 characters in the search bar above...". The local list still shows. 3. **Type "pasta" in the search bar** (300ms debounce). Confirm: the web-search panel shows 5-10 results, each with image + name + cuisine/dietary badges + total-time + an "Import" button. The local list is unchanged. 4. **Click "Import" on a result.** Confirm: button label flips to "Imported" (with a check icon), toast appears with "Imported """, and the local list re-fetches (the new recipe is in the list). Click another import button — confirm the same flow. ## Quota test (manual, post-deploy) ```bash # Hit search 50 times. Each call costs 1.1 points. for i in $(seq 1 50); do curl -sS 'http://100.108.208.56:8082/api/recipes/search?q=test&limit=10' \ -H 'Cookie: mealplanner_session=...' > /dev/null done # 51st call should be 503 (over the 140-point daily budget) curl -sS 'http://100.108.208.56:8082/api/recipes/search?q=test&limit=10' \ -H 'Cookie: mealplanner_session=...' -i | head -1 # Expected: HTTP/1.1 503 Service Unavailable ``` The 140-point daily budget leaves a 10-point safety margin under the 150-point free tier. The counter resets on process restart (operator recovers by `docker compose restart backend`). ## A11y check - The toggle is a real `