Public Access
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
468 lines
14 KiB
Python
468 lines
14 KiB
Python
"""Tests for the Phase 5 weekly orchestration."""
|
|
import pytest
|
|
from datetime import date, datetime, timezone
|
|
from uuid import uuid4
|
|
|
|
pytestmark = pytest.mark.requires_postgres
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
WEEK = date(2026, 5, 8) # a Friday
|
|
|
|
|
|
@pytest.fixture()
|
|
def family(db):
|
|
from app.models import FamilyProfile
|
|
fp = FamilyProfile(
|
|
id=uuid4(),
|
|
name="Test Family",
|
|
household_size=4,
|
|
adult_count=2,
|
|
child_count=2,
|
|
pending_approval_policy="approve",
|
|
)
|
|
db.add(fp)
|
|
db.flush()
|
|
return fp
|
|
|
|
|
|
@pytest.fixture()
|
|
def member(db, family):
|
|
from app.models import FamilyMember, FamilyMemberRole
|
|
m = FamilyMember(
|
|
id=uuid4(),
|
|
family_profile_id=family.id,
|
|
name="Alice",
|
|
email="alice@example.com",
|
|
role=FamilyMemberRole.ADULT,
|
|
)
|
|
db.add(m)
|
|
db.flush()
|
|
return m
|
|
|
|
|
|
@pytest.fixture()
|
|
def weekly_run(db, family):
|
|
from app.models import WeeklyRun
|
|
r = WeeklyRun(family_id=family.id, week_start_date=WEEK, status="pending")
|
|
db.add(r)
|
|
db.flush()
|
|
return r
|
|
|
|
|
|
@pytest.fixture()
|
|
def weekly_run_scraped(db, family):
|
|
from app.models import WeeklyRun
|
|
r = WeeklyRun(
|
|
family_id=family.id,
|
|
week_start_date=WEEK,
|
|
status="running",
|
|
scraped_at=datetime.now(timezone.utc),
|
|
)
|
|
db.add(r)
|
|
db.flush()
|
|
return r
|
|
|
|
|
|
@pytest.fixture()
|
|
def weekly_run_generated(db, family):
|
|
from app.models import WeeklyRun
|
|
r = WeeklyRun(
|
|
family_id=family.id,
|
|
week_start_date=WEEK,
|
|
status="running",
|
|
scraped_at=datetime.now(timezone.utc),
|
|
generated_at=datetime.now(timezone.utc),
|
|
)
|
|
db.add(r)
|
|
db.flush()
|
|
return r
|
|
|
|
|
|
@pytest.fixture()
|
|
def meal_plan(db, family):
|
|
from app.models import MealPlan, MealPlanStatus
|
|
mp = MealPlan(
|
|
id=uuid4(),
|
|
family_profile_id=family.id,
|
|
week_start_date=WEEK,
|
|
status=MealPlanStatus.PENDING_APPROVAL,
|
|
)
|
|
db.add(mp)
|
|
db.flush()
|
|
return mp
|
|
|
|
|
|
@pytest.fixture()
|
|
def pending_item(db, meal_plan):
|
|
from app.models import MealPlanItem, MealPlanItemStatus, MealType, Recipe
|
|
recipe = Recipe(
|
|
id=uuid4(),
|
|
name="Test Pasta",
|
|
servings=4,
|
|
ingredients=[{"name": "pasta", "qty": "400", "unit": "g"}],
|
|
instructions=["boil water", "cook pasta"],
|
|
)
|
|
db.add(recipe)
|
|
db.flush()
|
|
item = MealPlanItem(
|
|
id=uuid4(),
|
|
meal_plan_id=meal_plan.id,
|
|
recipe_id=recipe.id,
|
|
day_of_week=5,
|
|
meal_type=MealType.DINNER,
|
|
approval_status=MealPlanItemStatus.PENDING,
|
|
)
|
|
db.add(item)
|
|
db.flush()
|
|
return item
|
|
|
|
|
|
@pytest.fixture()
|
|
def weekly_run_emailed(db, family):
|
|
from app.models import WeeklyRun
|
|
r = WeeklyRun(
|
|
family_id=family.id,
|
|
week_start_date=WEEK,
|
|
status="running",
|
|
scraped_at=datetime.now(timezone.utc),
|
|
generated_at=datetime.now(timezone.utc),
|
|
emailed_at=datetime.now(timezone.utc),
|
|
)
|
|
db.add(r)
|
|
db.flush()
|
|
return r
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# alerts.py tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_send_admin_alert_noop_when_email_unset(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.alerts.settings",
|
|
type("S", (), {"ADMIN_EMAIL": ""})(),
|
|
)
|
|
from app.services.orchestrator.alerts import send_admin_alert
|
|
send_admin_alert("test subject", "test body") # must not raise
|
|
|
|
|
|
def test_send_admin_alert_calls_backend(monkeypatch):
|
|
sent = []
|
|
|
|
class FakeBackend:
|
|
def send(self, **kwargs):
|
|
sent.append(kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.alerts.settings",
|
|
type("S", (), {"ADMIN_EMAIL": "admin@example.com"})(),
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.alerts.get_email_backend",
|
|
lambda: FakeBackend(),
|
|
)
|
|
from app.services.orchestrator.alerts import send_admin_alert
|
|
send_admin_alert("boom", "details here")
|
|
assert len(sent) == 1
|
|
assert "[MealPlanner ALERT] boom" in sent[0]["subject"]
|
|
assert sent[0]["to"] == "admin@example.com"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# step_scrape tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_step_scrape_idempotent(db, weekly_run_scraped):
|
|
original_ts = weekly_run_scraped.scraped_at
|
|
from app.services.orchestrator.steps import step_scrape
|
|
step_scrape(weekly_run_scraped, db)
|
|
assert weekly_run_scraped.scraped_at == original_ts
|
|
|
|
|
|
def test_step_scrape_success(db, weekly_run, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.steps.ScraperService.run_scrape",
|
|
lambda self, **kw: {"status": "success", "items_scraped": 42},
|
|
)
|
|
from app.services.orchestrator.steps import step_scrape
|
|
step_scrape(weekly_run, db)
|
|
assert weekly_run.scraped_at is not None
|
|
assert weekly_run.used_stale_data is False
|
|
|
|
|
|
def test_step_scrape_both_fail_marks_stale(db, weekly_run, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.steps.ScraperService.run_scrape",
|
|
lambda self, **kw: {"status": "failed", "error": "timeout"},
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.steps.send_admin_alert",
|
|
lambda subject, body: None,
|
|
)
|
|
from app.services.orchestrator.steps import step_scrape
|
|
step_scrape(weekly_run, db)
|
|
assert weekly_run.used_stale_data is True
|
|
assert weekly_run.scraped_at is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# step_generate tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_step_generate_idempotent(db, weekly_run_generated):
|
|
original_ts = weekly_run_generated.generated_at
|
|
from app.services.orchestrator.steps import step_generate
|
|
step_generate(weekly_run_generated, db)
|
|
assert weekly_run_generated.generated_at == original_ts
|
|
|
|
|
|
def test_step_generate_creates_plan(db, weekly_run_scraped, monkeypatch):
|
|
from uuid import uuid4
|
|
from app.services.planner.types import GenerationResult
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.steps.generate_meal_plan",
|
|
lambda db, **kw: GenerationResult(
|
|
meal_plan_id=uuid4(),
|
|
selected=[],
|
|
feasible_count=0,
|
|
rejected_summary={},
|
|
set_score=0.0,
|
|
),
|
|
)
|
|
from app.services.orchestrator.steps import step_generate
|
|
step_generate(weekly_run_scraped, db)
|
|
assert weekly_run_scraped.generated_at is not None
|
|
|
|
|
|
def test_step_generate_records_error_on_failure(db, weekly_run_scraped, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.steps.generate_meal_plan",
|
|
lambda db, **kw: (_ for _ in ()).throw(RuntimeError("no recipes")),
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.steps.send_admin_alert",
|
|
lambda subject, body: None,
|
|
)
|
|
from app.services.orchestrator.steps import step_generate
|
|
with pytest.raises(RuntimeError):
|
|
step_generate(weekly_run_scraped, db)
|
|
assert weekly_run_scraped.error_step == "generate"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# step_email tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_step_email_idempotent(db, weekly_run_emailed):
|
|
original_ts = weekly_run_emailed.emailed_at
|
|
from app.services.orchestrator.steps import step_email
|
|
step_email(weekly_run_emailed, db)
|
|
assert weekly_run_emailed.emailed_at == original_ts
|
|
|
|
|
|
def test_step_email_sends_per_member(db, weekly_run_generated, meal_plan, pending_item, member, monkeypatch):
|
|
sent = []
|
|
|
|
class FakeBackend:
|
|
def send(self, **kwargs):
|
|
sent.append(kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.steps.get_email_backend",
|
|
lambda: FakeBackend(),
|
|
)
|
|
from app.services.orchestrator.steps import step_email
|
|
step_email(weekly_run_generated, db)
|
|
assert weekly_run_generated.emailed_at is not None
|
|
assert len(sent) == 1
|
|
assert sent[0]["to"] == "alice@example.com"
|
|
assert str(WEEK) in sent[0]["subject"]
|
|
|
|
|
|
def test_step_email_stale_banner(db, weekly_run_generated, meal_plan, pending_item, member, monkeypatch):
|
|
weekly_run_generated.used_stale_data = True
|
|
sent = []
|
|
|
|
class FakeBackend:
|
|
def send(self, **kwargs):
|
|
sent.append(kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.steps.get_email_backend",
|
|
lambda: FakeBackend(),
|
|
)
|
|
from app.services.orchestrator.steps import step_email
|
|
step_email(weekly_run_generated, db)
|
|
assert "prices" in sent[0]["html"].lower() or "stale" in sent[0]["html"].lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# step_deadline tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_step_deadline_idempotent(db, weekly_run_emailed):
|
|
weekly_run_emailed.deadline_passed_at = datetime.now(timezone.utc)
|
|
db.flush()
|
|
original_ts = weekly_run_emailed.deadline_passed_at
|
|
from app.services.orchestrator.steps import step_deadline
|
|
step_deadline(weekly_run_emailed, db)
|
|
assert weekly_run_emailed.deadline_passed_at == original_ts
|
|
|
|
|
|
def test_step_deadline_resolves_pending_to_approved(db, weekly_run_emailed, meal_plan, pending_item, family):
|
|
from app.models import MealPlanItemStatus
|
|
from app.services.orchestrator.steps import step_deadline
|
|
family.pending_approval_policy = "approve"
|
|
db.flush()
|
|
step_deadline(weekly_run_emailed, db)
|
|
db.refresh(pending_item)
|
|
assert pending_item.approval_status == MealPlanItemStatus.APPROVED
|
|
assert weekly_run_emailed.deadline_passed_at is not None
|
|
|
|
|
|
def test_step_deadline_resolves_pending_to_denied(db, weekly_run_emailed, meal_plan, pending_item, family):
|
|
from app.models import MealPlanItemStatus
|
|
from app.services.orchestrator.steps import step_deadline
|
|
family.pending_approval_policy = "deny"
|
|
db.flush()
|
|
step_deadline(weekly_run_emailed, db)
|
|
db.refresh(pending_item)
|
|
assert pending_item.approval_status == MealPlanItemStatus.DENIED
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# step_finalize tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture()
|
|
def weekly_run_deadline_passed(db, family):
|
|
from app.models import WeeklyRun
|
|
r = WeeklyRun(
|
|
family_id=family.id,
|
|
week_start_date=WEEK,
|
|
status="running",
|
|
scraped_at=datetime.now(timezone.utc),
|
|
generated_at=datetime.now(timezone.utc),
|
|
emailed_at=datetime.now(timezone.utc),
|
|
deadline_passed_at=datetime.now(timezone.utc),
|
|
)
|
|
db.add(r)
|
|
db.flush()
|
|
return r
|
|
|
|
|
|
@pytest.fixture()
|
|
def approved_item(db, meal_plan):
|
|
from app.models import MealPlanItem, MealPlanItemStatus, MealType, Recipe
|
|
recipe = Recipe(
|
|
id=uuid4(),
|
|
name="Approved Chicken",
|
|
servings=4,
|
|
ingredients=[{"name": "chicken", "qty": "500", "unit": "g"}],
|
|
instructions=["cook it"],
|
|
)
|
|
db.add(recipe)
|
|
db.flush()
|
|
item = MealPlanItem(
|
|
id=uuid4(),
|
|
meal_plan_id=meal_plan.id,
|
|
recipe_id=recipe.id,
|
|
day_of_week=6,
|
|
meal_type=MealType.DINNER,
|
|
approval_status=MealPlanItemStatus.APPROVED,
|
|
)
|
|
db.add(item)
|
|
db.flush()
|
|
return item
|
|
|
|
|
|
def test_step_finalize_idempotent(db, weekly_run_deadline_passed):
|
|
weekly_run_deadline_passed.finalized_at = datetime.now(timezone.utc)
|
|
db.flush()
|
|
original_ts = weekly_run_deadline_passed.finalized_at
|
|
from app.services.orchestrator.steps import step_finalize
|
|
step_finalize(weekly_run_deadline_passed, db)
|
|
assert weekly_run_deadline_passed.finalized_at == original_ts
|
|
|
|
|
|
def test_step_finalize_sends_shopping_list(db, weekly_run_deadline_passed, meal_plan, approved_item, member, monkeypatch):
|
|
sent = []
|
|
|
|
class FakeBackend:
|
|
def send(self, **kwargs):
|
|
sent.append(kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.steps.get_email_backend",
|
|
lambda: FakeBackend(),
|
|
)
|
|
from app.services.orchestrator.steps import step_finalize
|
|
step_finalize(weekly_run_deadline_passed, db)
|
|
assert weekly_run_deadline_passed.finalized_at is not None
|
|
assert weekly_run_deadline_passed.status == "completed"
|
|
assert len(sent) == 1
|
|
assert "Shopping list" in sent[0]["subject"]
|
|
assert "chicken" in sent[0]["html"].lower()
|
|
|
|
|
|
def test_step_finalize_no_approved_sends_empty_message(db, weekly_run_deadline_passed, meal_plan, pending_item, member, monkeypatch):
|
|
sent = []
|
|
|
|
class FakeBackend:
|
|
def send(self, **kwargs):
|
|
sent.append(kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.steps.get_email_backend",
|
|
lambda: FakeBackend(),
|
|
)
|
|
from app.services.orchestrator.steps import step_finalize
|
|
step_finalize(weekly_run_deadline_passed, db)
|
|
assert weekly_run_deadline_passed.finalized_at is not None
|
|
assert "No meals were approved" in sent[0]["html"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# runner tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_run_step_upserts_weekly_run(db, family, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.steps.ScraperService.run_scrape",
|
|
lambda self, **kw: {"status": "success", "items_scraped": 0},
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.runner.SessionLocal",
|
|
lambda: db,
|
|
)
|
|
from app.services.orchestrator.runner import run_step
|
|
run_step("scrape", WEEK)
|
|
from app.models import WeeklyRun
|
|
run = db.query(WeeklyRun).filter(WeeklyRun.family_id == family.id).first()
|
|
assert run is not None
|
|
assert run.scraped_at is not None
|
|
|
|
|
|
def test_run_step_invalid_raises(monkeypatch):
|
|
from app.services.orchestrator.runner import run_step
|
|
with pytest.raises(ValueError, match="Unknown step"):
|
|
run_step("bogus", WEEK)
|
|
|
|
|
|
def test_run_week_calls_all_steps(monkeypatch):
|
|
called = []
|
|
|
|
def fake_run_step(step, week):
|
|
called.append(step)
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.orchestrator.runner.run_step",
|
|
fake_run_step,
|
|
)
|
|
from app.services.orchestrator.runner import run_week
|
|
run_week(WEEK)
|
|
assert called == ["scrape", "generate", "email", "deadline", "finalize"]
|