"""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 @pytest.fixture() def weekly_run_reminded(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), reminded_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_reminder ────────────────────────────────────────────────────────── def test_step_reminder_idempotent(db, weekly_run_reminded): original_ts = weekly_run_reminded.reminded_at from app.services.orchestrator.steps import step_reminder step_reminder(weekly_run_reminded, db) assert weekly_run_reminded.reminded_at == original_ts def test_step_reminder_skips_when_not_emailed(db, weekly_run_generated, 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_reminder step_reminder(weekly_run_generated, db) # emailed_at is None on weekly_run_generated — should be a no-op assert len(sent) == 0 assert weekly_run_generated.reminded_at is None def test_step_reminder_sends_to_non_voter( db, weekly_run_emailed, 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_reminder step_reminder(weekly_run_emailed, db) assert weekly_run_emailed.reminded_at is not None assert len(sent) == 1 assert sent[0]["to"] == "alice@example.com" assert "1 hour" in sent[0]["subject"].lower() or "closes" in sent[0]["subject"].lower() def test_step_reminder_skips_voter( db, weekly_run_emailed, meal_plan, pending_item, member, monkeypatch ): from app.models import MealPlanVote vote = MealPlanVote( meal_plan_item_id=pending_item.id, family_member_id=member.id, vote=True, ) db.add(vote) db.flush() 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_reminder step_reminder(weekly_run_emailed, db) # member already voted — no reminder assert len(sent) == 0 assert weekly_run_emailed.reminded_at is not None # --------------------------------------------------------------------------- # 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}, ) class _NoCloseSession: """Proxy that delegates all attribute access to the test session but turns close() into a no-op so the transactional fixture stays live.""" def __getattr__(self, name): return getattr(db, name) def close(self): pass monkeypatch.setattr( "app.services.orchestrator.runner.SessionLocal", lambda: _NoCloseSession(), ) 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", "reminder", "deadline", "finalize"] # --------------------------------------------------------------------------- # Admin endpoint tests # --------------------------------------------------------------------------- @pytest.fixture() def admin_client(db): from fastapi.testclient import TestClient from app.main import app from app.database import get_db app.dependency_overrides[get_db] = lambda: db client = TestClient(app) yield client app.dependency_overrides.clear() ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"} def test_orchestrate_status_returns_list(admin_client, family, monkeypatch): from app.config import settings monkeypatch.setattr(settings, "ADMIN_TOKEN", "test-admin-token") resp = admin_client.get("/api/admin/orchestrate/status", headers=ADMIN_HEADERS) assert resp.status_code == 200 assert "runs" in resp.json() def test_orchestrate_step_invalid_returns_400(admin_client, monkeypatch): from app.config import settings monkeypatch.setattr(settings, "ADMIN_TOKEN", "test-admin-token") resp = admin_client.post( "/api/admin/orchestrate/bogus", headers=ADMIN_HEADERS ) assert resp.status_code == 400 def test_orchestrate_run_week_returns_202(admin_client, monkeypatch): from app.config import settings monkeypatch.setattr(settings, "ADMIN_TOKEN", "test-admin-token") monkeypatch.setattr( "app.api.admin.run_week", lambda week=None: None, ) resp = admin_client.post( "/api/admin/orchestrate/run-week", headers=ADMIN_HEADERS ) assert resp.status_code == 202