Phase 3a: close the Django API gaps for the MCP server

Backend prerequisites for the FastMCP service (mcp.md §6) — no model
changes, no migration.

- log_cook: accepts `rating`, validates via full_clean (enforces the
  exactly-one-recipe rule), and returns `used_ingredients` as a
  suggestion set instead of mutating the pantry. Auto-deduct path and
  _deduct_ingredient removed — pantry state changes only via set-state
  after the user confirms.
- New POST /api/pantry/set-state/ — set in/low/out by ingredient name
  (alias-aware), optional location; registered before the router so the
  pantry detail route doesn't capture "set-state" as a pk.
- what_can_i_cook: presence-based (exclude 'out', tolerate null
  quantity) so the API matcher agrees with the web one.
- bulk-pantry-add: restocks an existing ingredient+location row to 'in'
  instead of duplicating; quantity is an optional int.

Tests cover all four + that pantry `state` is serialized (24 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Flux
2026-06-23 21:07:18 +01:00
co-authored by Claude Opus 4.8
parent 50dcaca1e8
commit 7255c9302c
3 changed files with 226 additions and 89 deletions
+128 -1
View File
@@ -1,8 +1,11 @@
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth.models import User
from rest_framework.test import APIClient
from kitchen.models import Ingredient, PantryItem, MetaRecipe, Slot, SlotOption
from kitchen.models import (
Ingredient, PantryItem, MetaRecipe, Slot, SlotOption, Recipe, CookLog,
)
class AuthTests(TestCase):
@@ -128,3 +131,127 @@ class PageSmokeTests(_AuthedTestCase):
for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"):
with self.subTest(page=name):
self.assertEqual(self.client.get(reverse(name)).status_code, 200)
# --- MCP-facing API (the Phase 3 gaps) ---
class _ApiTestCase(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user("api", password="pw")
self.client.force_authenticate(self.user)
class LogCookApiTests(_ApiTestCase):
def setUp(self):
super().setUp()
self.mr = MetaRecipe.objects.create(name="Stir Fry", method="fry it")
def test_accepts_rating(self):
r = self.client.post(
"/api/log-cook/", {"meta_recipe_id": self.mr.id, "rating": 5}, format="json"
)
self.assertEqual(r.status_code, 201)
self.assertEqual(CookLog.objects.get(id=r.data["cook_log_id"]).rating, 5)
def test_requires_exactly_one_recipe(self):
self.assertEqual(self.client.post("/api/log-cook/", {}, format="json").status_code, 400)
fixed = Recipe.objects.create(name="Beans", method="heat")
both = self.client.post(
"/api/log-cook/",
{"meta_recipe_id": self.mr.id, "recipe_id": fixed.id},
format="json",
)
self.assertEqual(both.status_code, 400)
def test_returns_used_ingredients_and_does_not_mutate_pantry(self):
noodles = Ingredient.objects.create(name="noodles", default_unit="nests")
item = PantryItem.objects.create(ingredient=noodles, location="cupboard", state="in")
slot = Slot.objects.create(meta_recipe=self.mr, name="carb", required=True)
SlotOption.objects.create(slot=slot, ingredient=noodles, quantity_per_serving=2, unit="nests")
r = self.client.post(
"/api/log-cook/",
{"meta_recipe_id": self.mr.id, "slot_choices": {"carb": "noodles"}},
format="json",
)
self.assertEqual(r.status_code, 201)
self.assertIn("noodles", [u["ingredient"] for u in r.data["used_ingredients"]])
item.refresh_from_db()
self.assertEqual(item.state, "in") # untouched — suggestions only
class SetStateApiTests(_ApiTestCase):
def setUp(self):
super().setUp()
self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests")
self.item = PantryItem.objects.create(
ingredient=self.noodles, location="cupboard", state="in"
)
def test_set_state_by_name(self):
r = self.client.post(
"/api/pantry/set-state/", {"ingredient": "noodles", "state": "out"}, format="json"
)
self.assertEqual(r.status_code, 200)
self.item.refresh_from_db()
self.assertEqual(self.item.state, "out")
def test_invalid_state_rejected(self):
r = self.client.post(
"/api/pantry/set-state/", {"ingredient": "noodles", "state": "bogus"}, format="json"
)
self.assertEqual(r.status_code, 400)
def test_unknown_ingredient_404(self):
r = self.client.post(
"/api/pantry/set-state/", {"ingredient": "saffron", "state": "in"}, format="json"
)
self.assertEqual(r.status_code, 404)
class BulkAddApiTests(_ApiTestCase):
def test_restocks_out_item_without_duplicating(self):
eggs = Ingredient.objects.create(name="eggs", default_unit="items")
PantryItem.objects.create(ingredient=eggs, location="fridge", state="out")
r = self.client.post(
"/api/bulk-pantry-add/",
{"items": [{"ingredient_name": "eggs", "location": "fridge"}]},
format="json",
)
self.assertEqual(r.status_code, 201)
rows = PantryItem.objects.filter(ingredient=eggs, location="fridge")
self.assertEqual(rows.count(), 1)
self.assertEqual(rows.first().state, "in")
class WhatCanICookApiTests(_ApiTestCase):
def setUp(self):
super().setUp()
self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests")
self.mr = MetaRecipe.objects.create(name="Noodles", method="boil")
slot = Slot.objects.create(meta_recipe=self.mr, name="carb", required=True)
SlotOption.objects.create(slot=slot, ingredient=self.noodles, quantity_per_serving=2, unit="nests")
def _status(self, resp):
return next(x for x in resp.data["results"] if x["name"] == "Noodles")["status"]
def test_present_with_no_quantity_is_ready(self):
PantryItem.objects.create(ingredient=self.noodles, location="cupboard", state="in")
r = self.client.get("/api/what-can-i-cook/")
self.assertEqual(r.status_code, 200)
self.assertEqual(self._status(r), "ready")
def test_out_item_makes_recipe_missing(self):
PantryItem.objects.create(ingredient=self.noodles, location="cupboard", state="out")
r = self.client.get("/api/what-can-i-cook/")
self.assertEqual(self._status(r), "missing")
class PantrySerializerApiTests(_ApiTestCase):
def test_state_is_serialized(self):
eggs = Ingredient.objects.create(name="eggs", default_unit="items")
PantryItem.objects.create(ingredient=eggs, location="fridge", state="low")
r = self.client.get("/api/pantry/")
self.assertEqual(r.status_code, 200)
self.assertTrue(any(row.get("state") == "low" for row in r.data))