End-to-end testing (MCP client -> FastMCP -> Django) surfaced a 500:
adding an item with an explicit `unit: null` (as add_to_pantry sends
for items without a unit) hit a NOT NULL violation, because
`item_data.get("unit", "items")` returns None when the key is present.
- views.bulk_pantry_add: `item_data.get("unit") or "items"` — tolerate
null/empty unit.
- mcp_server add_to_pantry: omit quantity/unit from the payload when
unset, so the API applies its own defaults.
- test: bulk-add with unit/quantity = null returns 201 (25 pass).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
271 lines
10 KiB
Python
271 lines
10 KiB
Python
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, Recipe, CookLog,
|
|
)
|
|
|
|
|
|
class AuthTests(TestCase):
|
|
def test_app_requires_login(self):
|
|
resp = self.client.get(reverse("app-pantry"))
|
|
self.assertEqual(resp.status_code, 302)
|
|
self.assertIn(reverse("login"), resp.url)
|
|
|
|
def test_login_grants_access(self):
|
|
User.objects.create_user("tom", password="pw")
|
|
self.assertTrue(self.client.login(username="tom", password="pw"))
|
|
resp = self.client.get(reverse("app-pantry"))
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
def test_api_is_not_redirected_to_login(self):
|
|
# /api/ uses DRF token auth; the /app/ login middleware must not touch it.
|
|
resp = self.client.get("/api/pantry/")
|
|
self.assertIn(resp.status_code, (401, 403))
|
|
self.assertNotEqual(resp.status_code, 302)
|
|
|
|
|
|
class _AuthedTestCase(TestCase):
|
|
def setUp(self):
|
|
self.client = Client()
|
|
self.user = User.objects.create_user("tester", password="pw")
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
class PantryStateTests(_AuthedTestCase):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.eggs = Ingredient.objects.create(name="eggs", default_unit="items")
|
|
self.item = PantryItem.objects.create(
|
|
ingredient=self.eggs, location="fridge", state="in"
|
|
)
|
|
|
|
def test_state_defaults_to_in(self):
|
|
self.assertEqual(self.item.state, "in")
|
|
|
|
def test_quantity_is_optional(self):
|
|
self.assertIsNone(self.item.quantity)
|
|
|
|
def test_set_state_endpoint(self):
|
|
resp = self.client.post(
|
|
reverse("app-pantry-set-state", args=[self.item.id]), {"to": "low"}
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.item.refresh_from_db()
|
|
self.assertEqual(self.item.state, "low")
|
|
|
|
def test_set_state_rejects_unknown_value(self):
|
|
self.client.post(
|
|
reverse("app-pantry-set-state", args=[self.item.id]), {"to": "bogus"}
|
|
)
|
|
self.item.refresh_from_db()
|
|
self.assertEqual(self.item.state, "in") # unchanged
|
|
|
|
def test_out_items_still_listed(self):
|
|
self.item.state = "out"
|
|
self.item.save()
|
|
resp = self.client.get(reverse("app-pantry"))
|
|
self.assertContains(resp, "eggs")
|
|
|
|
def test_add_creates_in_stock_item(self):
|
|
resp = self.client.post(
|
|
reverse("app-pantry-add"),
|
|
{"ingredient_name": "milk", "location": "fridge"},
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertTrue(
|
|
PantryItem.objects.filter(ingredient__name="milk", state="in").exists()
|
|
)
|
|
|
|
def test_add_restocks_existing_to_in(self):
|
|
self.item.state = "out"
|
|
self.item.save()
|
|
self.client.post(
|
|
reverse("app-pantry-add"),
|
|
{"ingredient_name": "eggs", "location": "fridge"},
|
|
)
|
|
items = PantryItem.objects.filter(ingredient=self.eggs, location="fridge")
|
|
self.assertEqual(items.count(), 1)
|
|
self.assertEqual(items.first().state, "in")
|
|
|
|
def test_search_matches_by_name(self):
|
|
resp = self.client.get(
|
|
reverse("app-pantry-search"), {"ingredient_name": "egg"}
|
|
)
|
|
self.assertContains(resp, "eggs")
|
|
|
|
|
|
class RecipesPresenceTests(_AuthedTestCase):
|
|
"""Presence-based matcher: having an ingredient (not 'out') satisfies a slot
|
|
regardless of quantity."""
|
|
|
|
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 test_present_ingredient_no_quantity_is_available(self):
|
|
PantryItem.objects.create(
|
|
ingredient=self.noodles, location="cupboard", state="in"
|
|
)
|
|
resp = self.client.get(reverse("app-recipes"))
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertContains(resp, "Noodles")
|
|
|
|
def test_out_ingredient_renders_without_error(self):
|
|
PantryItem.objects.create(
|
|
ingredient=self.noodles, location="cupboard", state="out"
|
|
)
|
|
resp = self.client.get(reverse("app-recipes"))
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
|
|
class PageSmokeTests(_AuthedTestCase):
|
|
def test_pages_render(self):
|
|
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")
|
|
|
|
def test_tolerates_null_unit_and_quantity(self):
|
|
# An MCP client may send unit/quantity as null for unknown items.
|
|
r = self.client.post(
|
|
"/api/bulk-pantry-add/",
|
|
{"items": [{"ingredient_name": "pork mince", "location": "fridge",
|
|
"unit": None, "quantity": None}]},
|
|
format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 201)
|
|
self.assertTrue(
|
|
PantryItem.objects.filter(ingredient__name="pork mince", location="fridge").exists()
|
|
)
|
|
|
|
|
|
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))
|