"""Tests for the /list shopping-list view. Two paths exercised: 1. plan with no slots → renders the "go to /plan" CTA 2. plan with slots + a (mocked) Mealie client returning canned recipes → aggregator runs and the rendered HTML contains the expected lines """ import unittest from datetime import date from unittest.mock import MagicMock, patch # Trigger import-time stubs (env, pymysql, oidc) before pulling cauldron.server. # Absolute import so unittest discover finds it. import os, sys sys.path.insert(0, os.path.dirname(__file__)) import _testenv # noqa: E402, F401 from cauldron import server as srv class _ListTestBase(unittest.TestCase): def setUp(self): self.client = srv.app.test_client() with self.client.session_transaction() as s: s["user"] = {"sub": "sub-cobb", "email": "cobb@sulkta.com", "name": "Cobb"} def _fake_db(*, plan, slots=None): fake = MagicMock() fake.get_user_household_id.return_value = 1 fake.get_or_create_plan.return_value = dict(plan) fake.list_plan_slots.return_value = slots or [] fake.list_household_member_subs.return_value = ["sub-1"] def _enrich(p): p["slots"] = slots or [] return p fake.enrich_plan_with_slots.side_effect = _enrich # connection ctx for any auxiliary lookups (none needed here, but defensive) from contextlib import contextmanager @contextmanager def _conn(): yield FakeConn() fake.conn.side_effect = _conn return fake class FakeCursor: def execute(self, *a, **kw): pass def fetchone(self): return None def fetchall(self): return [] def __enter__(self): return self def __exit__(self, *a): return False class FakeConn: def cursor(self): return FakeCursor() def commit(self): pass def rollback(self): pass def close(self): pass class TestListNoPlan(_ListTestBase): def test_list_renders_no_plan(self): plan = { "id": 99, "household_id": 1, "week_start": date(2026, 4, 27), "generated_by_sub": None, "generated_at": None, "locked_by_sub": None, "locked_at": None, "locked_reason": None, } fake_db = _fake_db(plan=plan, slots=[]) with patch.object(srv, "db", fake_db): r = self.client.get("/list") self.assertEqual(r.status_code, 200) body = r.get_data(as_text=True).lower() # CTA text self.assertIn("no plan yet", body) self.assertIn("/plan", body) class TestListAggregated(_ListTestBase): def test_list_renders_aggregated(self): plan = { "id": 100, "household_id": 1, "week_start": date(2026, 4, 27), "generated_by_sub": "sub-cobb", "generated_at": None, "locked_by_sub": None, "locked_at": None, "locked_reason": None, } slots = [ {"id": 1, "plan_id": 100, "day": "monday", "recipe_slug": "stew", "recipe_name": "Stew", "source": "mealie", "picker_subs": [], "reason": "", "notes": None, "created_at": None}, {"id": 2, "plan_id": 100, "day": "tuesday", "recipe_slug": "rice-bowl", "recipe_name": "Rice Bowl", "source": "mealie", "picker_subs": [], "reason": "", "notes": None, "created_at": None}, ] # Canned Mealie recipes: stew has 1 lb rice, rice-bowl has 2 cup rice # → density-mixed agg should produce a single rice line fake_mealie = MagicMock() def _get_recipe(slug): if slug == "stew": return { "name": "Stew", "recipeIngredient": [ {"quantity": 1, "unit": {"name": "lb"}, "food": {"name": "rice"}, "note": "", "display": "1 lb rice"}, {"quantity": 2, "unit": {"name": "tbsp"}, "food": {"name": "olive oil"}, "note": "", "display": "2 tbsp olive oil"}, ], } if slug == "rice-bowl": return { "name": "Rice Bowl", "recipeIngredient": [ {"quantity": 2, "unit": {"name": "cup"}, "food": {"name": "rice"}, "note": "", "display": "2 cups rice"}, {"quantity": 1, "unit": {"name": "cup"}, "food": {"name": "olive oil"}, "note": "", "display": "1 cup olive oil"}, ], } raise KeyError(slug) fake_mealie.get_recipe.side_effect = _get_recipe fake_db = _fake_db(plan=plan, slots=slots) # foods.search_food returns canonical names + density for rice + olive oil def _search_food(db, name, *, limit=1): n = (name or "").strip().lower() if "rice" in n: return [{"id": 1, "canonical_name": "rice", "density_g_per_ml": 0.85, "default_unit_class": "mass", "common_size_g": None, "category": "grain"}] if "olive" in n or "oil" in n: return [{"id": 2, "canonical_name": "olive oil", "density_g_per_ml": 0.92, "default_unit_class": "volume", "common_size_g": None, "category": "oils"}] return [] with patch.object(srv, "db", fake_db), \ patch.object(srv, "current_user_mealie", create=True), \ patch("cauldron.foods.search_food", side_effect=_search_food): # patch the closure-bound current_user_mealie inside server.py # via patching the helper Mealie constructor route with patch("cauldron.server.Mealie", return_value=fake_mealie): # Make the user-token blob path return a fake encrypted blob fake_db.get_user_mealie_token_blob.return_value = b"fake-blob" with patch.object(srv.crypto, "decrypt", return_value="t"): r = self.client.get("/list") self.assertEqual(r.status_code, 200, r.get_data(as_text=True)) body = r.get_data(as_text=True).lower() # Rice should appear exactly once as an aggregated line; olive oil too self.assertIn("rice", body) self.assertIn("olive oil", body) # Aggregated rice: 1 lb + 2 cup × 0.85 g/ml × 236.588 ml/cup # = 453.6g + 402g = ~855g → display_mass picks lb (rounded .25) self.assertIn("lb", body) # localStorage data attribute should be present self.assertIn('data-plan-id="100"', body) if __name__ == "__main__": unittest.main()