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))