v0.3 step 3+4: AI plan generator + /list shopping aggregation

- migrations 012 + 013: cauldron_meal_plan_slots + cauldron_pick_points
- db: list_plan_slots, save_plan_slots, delete_plan_slots, mark_plan_generated,
  clear_plan_generated, award_pick_points, enrich_plan_with_slots; scoreboard
  extended with points (sum from pick_points) and weeks_locked alias
- forge.generate_plan: sonnet prompt builds 7-day plan respecting picks,
  validates slot count + day uniqueness + slug-in-pool, fills picker_subs
  from ground-truth picks (model output is advisory)
- POST /api/plan/generate: race-safe (existing slots → 409 with plan),
  lock-aware (locked → 409), idempotent
- POST /api/plan/regenerate: re-roll for the original generator, gated by
  ownership + lock; wipes slots + pick_points then re-runs generate
- plan.html: generate CTA + 7 day cards with picker chips + AI reason +
  re-roll button (generator-only, pre-lock); scoreboard now shows points + wins
- /list: pulls plan slots, queries Mealie for ingredients, runs aggregator,
  renders 48px-tall checkbox shopping list with localStorage state per plan_id
- tests: 13 new tests across forge.generate_plan + /api/plan/generate routes
  + /list view + scoreboard SQL inspection. conftest+_testenv stub
  pymysql/oidc/foods at import time so tests run against module-level app
  without a live DB. Both pytest and `unittest discover` paths green (27/27).

Defers: bulk sterilizer admin (A1), foods dedupe (A2), Mealie shopping-list-
export (button rendered but disabled). 7-slot count is fixed at the
endpoint (no UI for slot-count selection yet).

Spec: memory/spec-cauldron-v0.3.md
This commit is contained in:
Kayos 2026-04-29 06:26:54 -07:00
parent cc6222139d
commit 36aba73f66
9 changed files with 1724 additions and 33 deletions

85
tests/_testenv.py Normal file
View file

@ -0,0 +1,85 @@
"""Test environment bootstrap.
Cauldron's server module instantiates a DB + OAuth client + Mealie client at
import time, so tests must stub those before importing cauldron.server.
This module performs that stubbing exactly once (idempotent safe to import
multiple times). Both pytest's conftest.py and direct `unittest discover`
runs route through here.
"""
import os
from unittest.mock import MagicMock, patch
from cryptography.fernet import Fernet
_BOOTSTRAPPED = False
def _stub_env() -> None:
os.environ.setdefault("SECRET_KEY", "test-secret")
os.environ.setdefault("MEALIE_BASE_URL", "http://mealie.test")
os.environ.setdefault("MEALIE_API_TOKEN", "mealie-test-token")
os.environ.setdefault("CLAWDFORGE_URL", "http://forge.test")
os.environ.setdefault("CLAWDFORGE_TOKEN", "forge-test-token")
os.environ.setdefault("ADMIN_BEARER", "admin-test-bearer")
os.environ.setdefault("OIDC_ISSUER", "http://authentik.test/application/o/cauldron")
os.environ.setdefault("OIDC_CLIENT_ID", "test-client")
os.environ.setdefault("OIDC_CLIENT_SECRET", "test-secret-client")
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost/auth/callback")
os.environ.setdefault("DB_HOST", "localhost")
os.environ.setdefault("DB_NAME", "cauldron_test")
os.environ.setdefault("DB_USER", "cauldron")
os.environ.setdefault("DB_PASSWORD", "test")
os.environ.setdefault("CAULDRON_FERNET_KEY", Fernet.generate_key().decode())
class _FakeCursor:
def __init__(self):
self.lastrowid = 0
self.rowcount = 0
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 __init__(self, *a, **kw): pass
def cursor(self): return _FakeCursor()
def commit(self): pass
def rollback(self): pass
def close(self): pass
def bootstrap() -> None:
"""Apply env + import-time monkey patches. Idempotent."""
global _BOOTSTRAPPED
if _BOOTSTRAPPED:
return
_stub_env()
# pymysql.connect → no-op fake
patch("pymysql.connect", _FakeConn).start()
# Pre-import dotted modules so the next patches resolve
import cauldron.db # noqa: F401
import cauldron.oidc # noqa: F401
import cauldron.foods # noqa: F401
# Skip migrations
patch("cauldron.db.DB.migrate", lambda self: []).start()
# Skip OIDC metadata fetch
oauth_obj = MagicMock()
oauth_obj.cauldron = MagicMock()
patch("cauldron.oidc.init_oauth", lambda *a, **kw: oauth_obj).start()
# Skip foods seed loader
patch("cauldron.foods.load_seed_if_empty", lambda db: 0).start()
_BOOTSTRAPPED = True
bootstrap()

5
tests/conftest.py Normal file
View file

@ -0,0 +1,5 @@
"""Pytest auto-loaded conftest. Routes through _testenv so both pytest and
`unittest discover` see the same stubs."""
import os, sys
sys.path.insert(0, os.path.dirname(__file__))
import _testenv # noqa: E402, F401

175
tests/test_list_view.py Normal file
View file

@ -0,0 +1,175 @@
"""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()

View file

@ -0,0 +1,381 @@
"""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, <sub>, 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()