Public Access
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>
238 lines
7.0 KiB
Python
238 lines
7.0 KiB
Python
"""
|
|
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"
|