#!/usr/bin/env python """ R2-B end-to-end approval round-trip spike. Usage: # Print URL only (you can click it in a browser running the server): python scripts/send_test_approval.py # Bypass the email and exercise the full POST against a TestClient: python scripts/send_test_approval.py --simulate-click approve python scripts/send_test_approval.py --simulate-click deny This is the actual proof that the email + per-voter approval click round-trip works against the real schema (`family_member`, `meal_plan_item`, `meal_plan_vote`). If the script exits 0 with `item_status=approved` (or `denied`) the spike is green. """ from __future__ import annotations import argparse import os import sys import uuid from datetime import date, timedelta from pathlib import Path # Make backend/ importable when invoked from repo root. _REPO_ROOT = Path(__file__).resolve().parent.parent _BACKEND_ROOT = _REPO_ROOT / "backend" if str(_BACKEND_ROOT) not in sys.path: sys.path.insert(0, str(_BACKEND_ROOT)) # DATABASE_URL must be set before importing app.config. os.environ.setdefault( "DATABASE_URL", os.environ.get( "TEST_DATABASE_URL", "postgresql://mealplanner:password@localhost:5432/mealplanner_test", ), ) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--simulate-click", choices=["approve", "deny"], help="Bypass email; POST the vote via TestClient and assert success.", ) parser.add_argument( "--base-url", default="http://localhost:8000", help="Base URL for the printed link (informational only).", ) args = parser.parse_args() from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app.config import settings from app.models import ( FamilyMember, FamilyMemberRole, FamilyProfile, MealPlan, MealPlanItem, MealPlanStatus, MealType, Recipe, ) from app.services import approval as approval_service from app.services.email import get_email_backend engine = create_engine( settings.DATABASE_URL.replace("postgresql://", "postgresql+psycopg2://") ) Session = sessionmaker(bind=engine, autoflush=False, autocommit=False) db = Session() # ------------------------------------------------------------------ # Bootstrap a minimal scenario. Suffix everything with a UUID4 so the # script is idempotent and can run repeatedly without unique-constraint # collisions. # ------------------------------------------------------------------ suffix = uuid.uuid4().hex[:8] profile = FamilyProfile( name=f"Spike Family {suffix}", household_size=1, adult_count=1, child_count=0, ) db.add(profile) db.flush() voter = FamilyMember( family_profile_id=profile.id, name="Spike Voter", email=f"spike+{suffix}@example.com", role=FamilyMemberRole.ADULT, ) db.add(voter) db.flush() recipe = Recipe( family_profile_id=profile.id, name=f"Spike Pasta {suffix}", servings=2, ingredients=[{"name": "pasta", "qty": "200g"}], instructions=["Boil water", "Cook pasta"], is_manually_added=True, ) db.add(recipe) db.flush() plan = MealPlan( family_profile_id=profile.id, week_start_date=date.today() + timedelta(days=(7 - date.today().weekday())), status=MealPlanStatus.DRAFT, ) db.add(plan) db.flush() item = MealPlanItem( meal_plan_id=plan.id, recipe_id=recipe.id, day_of_week=1, meal_type=MealType.DINNER, ) db.add(item) db.commit() token = approval_service.issue_token(item.id, voter.id) url = f"{args.base_url}/api/meals/vote/{item.id}?token={token}" # Send via configured email backend (Console writes to stdout + outbox). backend = get_email_backend() subject = "Action required: please vote on this week's meal" html = ( f"

Hi {voter.name}, please review this meal:

" f"

{recipe.name}

" f'

Open the approval page

' ) text = f"Hi {voter.name}, please review {recipe.name}: {url}" backend.send(to=voter.email, subject=subject, html=html, text=text) print(f"item_id={item.id}") print(f"voter_id={voter.id}") print(f"approval_url={url}") if args.simulate_click is None: return 0 # ------------------------------------------------------------------ # End-to-end proof: drive the POST through a TestClient. # ------------------------------------------------------------------ from fastapi.testclient import TestClient from app.main import app with TestClient(app) as client: # Render the GET page first to mirror a real click. r_get = client.get(f"/api/meals/vote/{item.id}", params={"token": token}) assert r_get.status_code == 200, (r_get.status_code, r_get.text) assert "text/html" in r_get.headers.get("content-type", "") r_post = client.post( f"/api/meals/vote/{item.id}", params={"token": token}, json={"vote": args.simulate_click}, ) assert r_post.status_code == 200, (r_post.status_code, r_post.text) body = r_post.json() print(f"item_status={body['item_status']}") assert body["status"] == "recorded" # Single-use: a second POST must 409. r_post2 = client.post( f"/api/meals/vote/{item.id}", params={"token": token}, json={"vote": args.simulate_click}, ) assert r_post2.status_code == 409, (r_post2.status_code, r_post2.text) print("single_use=enforced") return 0 if __name__ == "__main__": sys.exit(main())