Public Access
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:
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
R2-B: approval-token + per-voter vote round-trip tests.
|
||||
|
||||
- Pure unit tests for `app.services.approval` (no DB).
|
||||
- DB-backed tests for `consume_token` single-use + the two vote routes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services import approval as approval_service
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure-unit token tests.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_issue_and_verify_token_roundtrip():
|
||||
item_id = uuid.uuid4()
|
||||
voter_id = uuid.uuid4()
|
||||
token = approval_service.issue_token(item_id, voter_id)
|
||||
payload = approval_service.verify_token(token)
|
||||
assert payload["item"] == str(item_id)
|
||||
assert payload["voter"] == str(voter_id)
|
||||
|
||||
|
||||
def test_verify_rejects_tampered_token():
|
||||
item_id = uuid.uuid4()
|
||||
voter_id = uuid.uuid4()
|
||||
token = approval_service.issue_token(item_id, voter_id)
|
||||
tampered = token[:-2] + ("AA" if not token.endswith("AA") else "BB")
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
approval_service.verify_token(tampered)
|
||||
assert excinfo.value.status_code == 401
|
||||
|
||||
|
||||
def test_verify_rejects_expired_token(monkeypatch):
|
||||
"""itsdangerous reads `time.time()` — patch the time module's `time`
|
||||
attribute on `itsdangerous.timed` to simulate clock advance.
|
||||
"""
|
||||
import itsdangerous.timed as _timed_mod
|
||||
|
||||
item_id = uuid.uuid4()
|
||||
voter_id = uuid.uuid4()
|
||||
|
||||
# Issue at "now".
|
||||
token = approval_service.issue_token(item_id, voter_id)
|
||||
|
||||
# Advance the clock 8 days past issue time (default TTL is 7 days).
|
||||
real_now = time.time()
|
||||
fake_now = real_now + (8 * 24 * 3600)
|
||||
|
||||
class _FakeTime:
|
||||
@staticmethod
|
||||
def time():
|
||||
return fake_now
|
||||
|
||||
monkeypatch.setattr(_timed_mod, "time", _FakeTime)
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
approval_service.verify_token(token)
|
||||
assert excinfo.value.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB-backed fixtures: build a minimal scenario reused by several tests.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture()
|
||||
def scenario(db):
|
||||
"""Create profile + 2 voters + recipe + plan + item, all in one shot.
|
||||
|
||||
Uses the per-test transactional session, so changes roll back on
|
||||
teardown (no cross-test pollution).
|
||||
"""
|
||||
from app.models import (
|
||||
FamilyMember,
|
||||
FamilyMemberRole,
|
||||
FamilyProfile,
|
||||
MealPlan,
|
||||
MealPlanItem,
|
||||
MealPlanStatus,
|
||||
MealType,
|
||||
Recipe,
|
||||
)
|
||||
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
profile = FamilyProfile(
|
||||
name=f"Test Family {suffix}",
|
||||
household_size=2,
|
||||
adult_count=2,
|
||||
child_count=0,
|
||||
)
|
||||
db.add(profile)
|
||||
db.flush()
|
||||
|
||||
voter_a = FamilyMember(
|
||||
family_profile_id=profile.id,
|
||||
name="Alice",
|
||||
email=f"alice+{suffix}@example.com",
|
||||
role=FamilyMemberRole.ADULT,
|
||||
)
|
||||
voter_b = FamilyMember(
|
||||
family_profile_id=profile.id,
|
||||
name="Bob",
|
||||
email=f"bob+{suffix}@example.com",
|
||||
role=FamilyMemberRole.ADULT,
|
||||
)
|
||||
db.add_all([voter_a, voter_b])
|
||||
db.flush()
|
||||
|
||||
recipe = Recipe(
|
||||
family_profile_id=profile.id,
|
||||
name=f"Test Pasta {suffix}",
|
||||
servings=2,
|
||||
ingredients=[{"name": "pasta", "qty": "200g"}],
|
||||
instructions=["Boil", "Drain"],
|
||||
is_manually_added=True,
|
||||
)
|
||||
db.add(recipe)
|
||||
db.flush()
|
||||
|
||||
plan = MealPlan(
|
||||
family_profile_id=profile.id,
|
||||
week_start_date=date.today() + timedelta(days=14),
|
||||
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.flush()
|
||||
|
||||
return {
|
||||
"profile": profile,
|
||||
"voter_a": voter_a,
|
||||
"voter_b": voter_b,
|
||||
"recipe": recipe,
|
||||
"plan": plan,
|
||||
"item": item,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB-backed tests.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.requires_postgres
|
||||
def test_consume_token_single_use(db, scenario):
|
||||
from app.models import MealPlanVote
|
||||
|
||||
item = scenario["item"]
|
||||
voter = scenario["voter_a"]
|
||||
token = approval_service.issue_token(item.id, voter.id)
|
||||
|
||||
# First call: succeeds, returns the voter.
|
||||
out = approval_service.consume_token(db, token, item.id)
|
||||
assert out.id == voter.id
|
||||
|
||||
# Simulate the route writing the vote row (consume_token itself does NOT
|
||||
# write — single-use is enforced by the presence of the vote row).
|
||||
db.add(MealPlanVote(
|
||||
meal_plan_item_id=item.id,
|
||||
family_member_id=voter.id,
|
||||
vote=True,
|
||||
))
|
||||
db.flush()
|
||||
|
||||
# Second call: raises 409.
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
approval_service.consume_token(db, token, item.id)
|
||||
assert excinfo.value.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.requires_postgres
|
||||
def test_vote_get_renders_html(client, db, scenario):
|
||||
item = scenario["item"]
|
||||
voter = scenario["voter_a"]
|
||||
token = approval_service.issue_token(item.id, voter.id)
|
||||
|
||||
r = client.get(f"/api/meals/vote/{item.id}", params={"token": token})
|
||||
assert r.status_code == 200, r.text
|
||||
assert "text/html" in r.headers.get("content-type", "")
|
||||
# Voter name appears in the visible body.
|
||||
assert "Alice" in r.text
|
||||
# Token is allowed inside the form `action` attribute (the link's href)
|
||||
# but must not appear in the visible meal-card text.
|
||||
body = r.text
|
||||
card_start = body.find('<div class="meal">')
|
||||
card_end = body.find("</div>", card_start)
|
||||
assert card_start != -1 and card_end != -1
|
||||
visible_card = body[card_start:card_end]
|
||||
assert token not in visible_card
|
||||
|
||||
|
||||
@pytest.mark.requires_postgres
|
||||
def test_vote_post_records_and_decides(client, db, scenario):
|
||||
"""First voter approves -> still pending (Bob hasn't voted).
|
||||
|
||||
Then Bob approves -> approved.
|
||||
"""
|
||||
item = scenario["item"]
|
||||
voter_a = scenario["voter_a"]
|
||||
voter_b = scenario["voter_b"]
|
||||
|
||||
token_a = approval_service.issue_token(item.id, voter_a.id)
|
||||
r1 = client.post(
|
||||
f"/api/meals/vote/{item.id}",
|
||||
params={"token": token_a},
|
||||
json={"vote": "approve"},
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
body1 = r1.json()
|
||||
assert body1["status"] == "recorded"
|
||||
assert body1["item_status"] in ("pending", "approved")
|
||||
# With 2 voters, after 1 approve we should still be pending.
|
||||
assert body1["item_status"] == "pending"
|
||||
|
||||
token_b = approval_service.issue_token(item.id, voter_b.id)
|
||||
r2 = client.post(
|
||||
f"/api/meals/vote/{item.id}",
|
||||
params={"token": token_b},
|
||||
json={"vote": "approve"},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
body2 = r2.json()
|
||||
assert body2["item_status"] == "approved"
|
||||
Reference in New Issue
Block a user