feat: phase r1+r2 recovery + r3-0 swiftly api ingestion

R1 stabilization: pytest harness with transactional db fixture, smoke
+ alembic + auth + scrape + approval + swiftly tests, github actions
ci yaml. Bearer-token admin auth + signed-cookie session for family
ui mutations. Async POST /api/admin/scrape (BackgroundTasks, returns
202). Path canonicalization (no /list, /planned suffixes). DATABASE_URL
fail-fast on empty.

R2 deferred-risk spikes: live lucky california fetch (R2-A), full
email+per-voter approval click round trip with single-use enforcement
(R2-B, console email backend, sendgrid stub).

R3-0 phase 3 redesign: replaced playwright html scraper with requests
based swiftly json api client. 17 categories, ~10k products per scrape,
upsert by (source, external_id). 401 surfaces actionable token-refresh
message via ScrapeLog.error_message.

Pre-existing defects fixed: shopping_list.py syntax error blocking app
import, MealPlan.votes orphan relationship, JSONB(astext=True) invalid
kwarg, missing requests dep, calorie_target schema drift, every SQLEnum
needed values_callable, 0001 had empty downgrade(), seed had duplicate
ingredient rows.

Migrations added: 0003 grocery_item.description, 0004 family_profile.
calorie_target, 0005 grocery_item.external_id + source + composite index.

Verified: 31/31 pytest green, alembic upgrade->downgrade->upgrade clean,
frontend npm run build clean, live scrape 9,960 grocery_item rows in 36s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 14:08:19 -07:00
co-authored by Claude Opus 4.7
parent b9434967ed
commit 8e89f793d5
58 changed files with 3594 additions and 348 deletions
+191
View File
@@ -0,0 +1,191 @@
#!/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"<p>Hi {voter.name}, please review this meal:</p>"
f"<p><strong>{recipe.name}</strong></p>"
f'<p><a href="{url}">Open the approval page</a></p>'
)
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())