From d118726d3010d5b0133c7665fc20008c055f2757 Mon Sep 17 00:00:00 2001 From: Tom Flux Date: Tue, 23 Jun 2026 21:07:18 +0100 Subject: Phase 3a: close the Django API gaps for the MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- kitchen/tests.py | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) (limited to 'kitchen/tests.py') diff --git a/kitchen/tests.py b/kitchen/tests.py index 065d530..9ce9098 100644 --- a/kitchen/tests.py +++ b/kitchen/tests.py @@ -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)) -- cgit v1.2.3