feat: orchestrator step_generate

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 06:30:50 -07:00
co-authored by Claude Sonnet 4.6
parent b2cbdd1533
commit 0a097da18e
2 changed files with 413 additions and 0 deletions
+218
View File
@@ -205,3 +205,221 @@ def test_step_scrape_both_fail_marks_stale(db, weekly_run, monkeypatch):
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"]