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