"""Tests for the AI plan generator (forge.generate_plan + the /api/plan/generate endpoint). The endpoint tests use Flask's test client. db methods on the module-level cauldron.server.db object are swapped out with MagicMocks per-test — this avoids needing a real MariaDB to test routing + the orchestration logic. """ import json import unittest from unittest.mock import MagicMock, patch # Run conftest's import-time patches BEFORE pulling in cauldron.server. # pytest auto-loads conftest, but unittest doesn't, so do it explicitly. # Import path is absolute so `unittest discover` (which doesn't treat tests/ # as a package) and pytest both resolve it. import os, sys sys.path.insert(0, os.path.dirname(__file__)) import _testenv # noqa: E402, F401 from cauldron import server as srv from cauldron.forge import Forge, ForgeError # ---------- forge.generate_plan unit tests -------------------------------- class TestForgeGeneratePlan(unittest.TestCase): def setUp(self): self.forge = Forge( base_url="http://forge.test", token="t", default_model="sonnet", default_timeout=60, ) def _ok_run(self, slots_payload): """Patch self.forge.run to return a dict shaped like clawdforge's.""" return patch.object( self.forge, "run", return_value={"result": {"slots": slots_payload}}, ) def test_validates_slot_count_matches(self): recipes = [ {"slug": "r1", "name": "Stew"}, {"slug": "r2", "name": "Tacos"}, {"slug": "r3", "name": "Pasta"}, {"slug": "r4", "name": "Pie"}, {"slug": "r5", "name": "Curry"}, {"slug": "r6", "name": "Bowl"}, {"slug": "r7", "name": "Soup"}, ] # Model returns only 5 slots — must raise bad = [{"day": d, "recipe_slug": "r1", "picker_subs": [], "reason": ""} for d in ("monday", "tuesday", "wednesday", "thursday", "friday")] with self._ok_run(bad): with self.assertRaises(ForgeError) as cm: self.forge.generate_plan(picks=[], recipes=recipes, slots=7, week_start="2026-04-27") self.assertIn("expected 7", str(cm.exception)) def test_rejects_unknown_slug(self): recipes = [{"slug": "r1", "name": "A"}] bad = [{"day": "monday", "recipe_slug": "r-not-real", "picker_subs": [], "reason": ""}] with self._ok_run(bad): with self.assertRaises(ForgeError) as cm: self.forge.generate_plan(picks=[], recipes=recipes, slots=1, week_start="2026-04-27") self.assertIn("unknown recipe_slug", str(cm.exception)) def test_rejects_duplicate_day(self): recipes = [{"slug": "r1", "name": "A"}, {"slug": "r2", "name": "B"}] bad = [ {"day": "monday", "recipe_slug": "r1", "picker_subs": [], "reason": ""}, {"day": "monday", "recipe_slug": "r2", "picker_subs": [], "reason": ""}, ] with self._ok_run(bad): with self.assertRaises(ForgeError) as cm: self.forge.generate_plan(picks=[], recipes=recipes, slots=2, week_start="2026-04-27") self.assertIn("duplicate day", str(cm.exception)) def test_picker_attribution_uses_real_subs(self): """Even if the model omits picker_subs, our ground-truth pick map is what ends up on the slot.""" recipes = [{"slug": "r1", "name": "Stew"}] picks = [{"slug": "r1", "name": "Stew", "picker_subs": ["sub-abby", "sub-cobb"]}] # Model returns empty picker_subs — we should fill from the picks slots_in = [{"day": "monday", "recipe_slug": "r1", "picker_subs": [], "reason": "honors picks"}] with self._ok_run(slots_in): out = self.forge.generate_plan( picks=picks, recipes=recipes, slots=1, week_start="2026-04-27", ) self.assertEqual(len(out), 1) self.assertEqual(out[0]["picker_subs"], ["sub-abby", "sub-cobb"]) self.assertEqual(out[0]["source"], "pick") self.assertEqual(out[0]["recipe_name"], "Stew") self.assertEqual(out[0]["reason"], "honors picks") def test_string_response_is_parsed(self): """clawdforge sometimes returns the JSON as a string in `result`.""" recipes = [{"slug": "r1", "name": "A"}] payload = {"slots": [{"day": "monday", "recipe_slug": "r1", "picker_subs": [], "reason": "ai"}]} with patch.object(self.forge, "run", return_value={"result": json.dumps(payload)}): out = self.forge.generate_plan( picks=[], recipes=recipes, slots=1, week_start="2026-04-27", ) self.assertEqual(len(out), 1) self.assertEqual(out[0]["recipe_slug"], "r1") self.assertEqual(out[0]["source"], "mealie") # no picks → mealie source def test_code_fenced_response_is_parsed(self): recipes = [{"slug": "r1", "name": "A"}] payload = {"slots": [{"day": "monday", "recipe_slug": "r1", "picker_subs": [], "reason": ""}]} fenced = "```json\n" + json.dumps(payload) + "\n```" with patch.object(self.forge, "run", return_value={"result": fenced}): out = self.forge.generate_plan( picks=[], recipes=recipes, slots=1, week_start="2026-04-27", ) self.assertEqual(out[0]["recipe_slug"], "r1") # ---------- /api/plan/generate route tests -------------------------------- def _make_db_stub(*, plan, picks=None, recipe_rows=None, existing_slots=None, save_inserted=None): """Build a fake db with the methods the route uses.""" fake = MagicMock() fake.list_household_picks_with_pickers.return_value = picks or [] fake.list_indexed_recipes.return_value = recipe_rows or [] fake.list_plan_slots.return_value = existing_slots or [] fake.get_or_create_plan.return_value = dict(plan) fake.auto_lock_past_unlocked_plans.return_value = 0 fake.list_household_member_subs.return_value = ["sub-1"] fake.get_user_household_id.return_value = 1 fake.list_household_pick_slugs.return_value = set() fake.household_scoreboard.return_value = [] fake.household_streak.return_value = None fake.upsert_user.return_value = None # save_plan_slots returns inserted count (1+ default, or override for race tests) if save_inserted is None: save_inserted = lambda plan_id, slots: len(slots) fake.save_plan_slots.side_effect = save_inserted # mark_plan_generated returns updated plan dict def _mark(plan_id, sub): p = dict(plan) p["generated_by_sub"] = sub from datetime import datetime p["generated_at"] = datetime(2026, 4, 27, 12, 0, 0) return p fake.mark_plan_generated.side_effect = _mark # enrich_plan_with_slots adds slots to the plan dict in-place def _enrich(p): p["slots"] = fake.list_plan_slots.return_value return p fake.enrich_plan_with_slots.side_effect = _enrich # conn() context manager stub for the display-name resolution from contextlib import contextmanager @contextmanager def _conn(): yield FakeConn() fake.conn.side_effect = _conn return fake class FakeCursor: def __init__(self): self._rows = [] 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 _RouteTestBase(unittest.TestCase): def setUp(self): self.client = srv.app.test_client() # Inject a session via a context override with self.client.session_transaction() as s: s["user"] = {"sub": "sub-cobb", "email": "cobb@sulkta.com", "name": "Cobb"} def _patch_db(self, fake_db): return patch.object(srv, "db", fake_db) class TestGenerateRoute(_RouteTestBase): def test_generate_creates_slots(self): from datetime import date plan = { "id": 42, "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, } recipe_rows = [ {"slug": f"r{i}", "name": f"Recipe {i}", "raw_json": "{}"} for i in range(1, 11) ] slots_returned = [ {"day": d, "recipe_slug": "r1", "recipe_name": "Recipe 1", "picker_subs": [], "reason": "ai", "source": "mealie"} for d in ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday") ] # Make slots use unique recipes per day for realism for i, s in enumerate(slots_returned): s["recipe_slug"] = f"r{i+1}" s["recipe_name"] = f"Recipe {i+1}" fake_db = _make_db_stub(plan=plan, recipe_rows=recipe_rows) with self._patch_db(fake_db), \ patch.object(srv.forge, "generate_plan", return_value=slots_returned): r = self.client.post("/api/plan/generate") self.assertEqual(r.status_code, 200, r.get_data(as_text=True)) body = r.get_json() self.assertTrue(body["ok"]) # save_plan_slots called with the plan id and the slots list fake_db.save_plan_slots.assert_called_once() args, _ = fake_db.save_plan_slots.call_args self.assertEqual(args[0], 42) self.assertEqual(len(args[1]), 7) self.assertEqual(args[1][0]["day"], "monday") # mark_plan_generated called with cobb's sub fake_db.mark_plan_generated.assert_called_once_with(42, "sub-cobb") def test_generate_when_locked_409(self): from datetime import date, datetime plan = { "id": 7, "household_id": 1, "week_start": date(2026, 4, 27), "generated_by_sub": None, "generated_at": None, "locked_by_sub": "sub-abby", "locked_at": datetime(2026, 4, 27, 18, 0), "locked_reason": "user", } fake_db = _make_db_stub(plan=plan) with self._patch_db(fake_db), \ patch.object(srv.forge, "generate_plan") as gp: r = self.client.post("/api/plan/generate") self.assertEqual(r.status_code, 409) self.assertEqual(r.get_json()["error"], "plan_locked") gp.assert_not_called() def test_generate_when_already_generated_409(self): from datetime import date plan = { "id": 9, "household_id": 1, "week_start": date(2026, 4, 27), "generated_by_sub": "sub-abby", "generated_at": None, "locked_by_sub": None, "locked_at": None, "locked_reason": None, } existing = [{ "id": 1, "plan_id": 9, "day": "monday", "recipe_slug": "r1", "recipe_name": "Stew", "source": "mealie", "picker_subs": [], "reason": "", "notes": None, "created_at": None, }] fake_db = _make_db_stub(plan=plan, existing_slots=existing) with self._patch_db(fake_db), \ patch.object(srv.forge, "generate_plan") as gp: r = self.client.post("/api/plan/generate") self.assertEqual(r.status_code, 409) body = r.get_json() self.assertEqual(body["error"], "plan_already_generated") self.assertIn("plan", body) self.assertEqual(len(body["plan"]["slots"]), 1) gp.assert_not_called() def test_pick_points_awarded_on_pick_use(self): from datetime import date plan = { "id": 11, "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, } recipe_rows = [ {"slug": "stew", "name": "Stew", "raw_json": "{}"}, {"slug": "tacos", "name": "Tacos", "raw_json": "{}"}, ] picks = [ {"slug": "stew", "name": "Stew", "pickers": ["abby"], "picker_subs": ["sub-abby"]}, {"slug": "tacos", "name": "Tacos", "pickers": ["cobb", "abby"], "picker_subs": ["sub-cobb", "sub-abby"]}, ] # Slot fixture: monday = stew (abby picks), tuesday = tacos (cobb + # abby picks), wed-sun = stew (ai-chosen, no pickers). slots_full = [] days = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday") for i, d in enumerate(days): if i == 0: slots_full.append({ "day": d, "recipe_slug": "stew", "recipe_name": "Stew", "picker_subs": ["sub-abby"], "reason": "abby's pick", "source": "pick", }) elif i == 1: slots_full.append({ "day": d, "recipe_slug": "tacos", "recipe_name": "Tacos", "picker_subs": ["sub-cobb", "sub-abby"], "reason": "co", "source": "pick", }) else: slots_full.append({ "day": d, "recipe_slug": "stew", "recipe_name": "Stew", "picker_subs": [], "reason": "ai", "source": "mealie", }) fake_db = _make_db_stub(plan=plan, picks=picks, recipe_rows=recipe_rows) with self._patch_db(fake_db), \ patch.object(srv.forge, "generate_plan", return_value=slots_full): r = self.client.post("/api/plan/generate") self.assertEqual(r.status_code, 200, r.get_data(as_text=True)) # 1pt for sub-abby on monday + 1pt sub-cobb + 1pt sub-abby on tuesday # = 3 award_pick_points calls total self.assertEqual(fake_db.award_pick_points.call_count, 3) # All calls should be (1, 11, , 1, "pick_used") called_subs = [c.args[2] for c in fake_db.award_pick_points.call_args_list] self.assertEqual(sorted(called_subs), ["sub-abby", "sub-abby", "sub-cobb"]) for call in fake_db.award_pick_points.call_args_list: self.assertEqual(call.args[3], 1) # points self.assertEqual(call.args[4], "pick_used") # ---------- household_scoreboard SQL test -------------------------------- class TestScoreboardSchema(unittest.TestCase): """The scoreboard SELECT must reference cauldron_pick_points and return a `points` field. Verified by inspecting the generated SQL via a capturing fake cursor.""" def test_scoreboard_query_includes_points(self): from cauldron.db import DB captured = {"sql": None} class CapCursor: def execute(self, sql, params=None): captured["sql"] = sql def fetchall(self): return [ { "sub": "sub-cobb", "email": "cobb@x.com", "display_name": "Cobb", "wins": 2, "last_win": None, "points": 5, }, ] def __enter__(self): return self def __exit__(self, *a): return False class CapConn: def cursor(self): return CapCursor() def commit(self): pass def rollback(self): pass def close(self): pass db = DB(host="x", port=3306, name="x", user="x", password="x") with patch("pymysql.connect", lambda **kw: CapConn()): rows = db.household_scoreboard(1) self.assertIn("cauldron_pick_points", captured["sql"]) self.assertIn("points", captured["sql"]) # And the row decoder coerces points to int + adds weeks_locked alias self.assertEqual(rows[0]["points"], 5) self.assertEqual(rows[0]["weeks_locked"], 2) if __name__ == "__main__": unittest.main()