Files
Meal-Planner/backend/tests/test_swiftly_api.py
T
adminandClaude Opus 4.7 8e89f793d5 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>
2026-05-05 14:08:19 -07:00

224 lines
8.0 KiB
Python

"""Offline tests for the Swiftly product-API client (R3-0).
All tests run against saved fixtures and mocked HTTP — no live network,
no Playwright/Chromium. Captured 2026-05-05 from a single live spike;
see ``.agent/context.md`` "Swiftly API" for the field-mapping rationale.
"""
from __future__ import annotations
import json
import sys
import uuid
from decimal import Decimal
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import requests
# Make `app.*` importable when pytest is invoked from the repo root.
BACKEND_DIR = Path(__file__).resolve().parent.parent
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from app.scraper.lucky_ca_scraper import ( # noqa: E402
LuckyCaliforniaScraper,
SwiftlyAuthError,
)
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "lucky_ca"
CATEGORIES_HTML = FIXTURE_DIR / "categories.html"
CATEGORY_JSON = FIXTURE_DIR / "category_meat_seafood.json"
pytestmark = pytest.mark.scraper_offline
# ---------------------------------------------------------------------------
# Pure-parser tests against captured fixtures
# ---------------------------------------------------------------------------
def test_parse_categories_fixture() -> None:
"""Parser returns >=10 distinct API slugs from the captured page."""
if not CATEGORIES_HTML.exists():
pytest.skip(f"fixture missing: {CATEGORIES_HTML}")
html = CATEGORIES_HTML.read_text(encoding="utf-8")
slugs = LuckyCaliforniaScraper.parse_categories_html(html)
assert len(slugs) >= 10, f"expected >=10 categories, got {len(slugs)}"
assert len(set(slugs)) == len(slugs), "slugs must be deduplicated"
# Every slug should look like `Product/<name>` per the API contract.
for s in slugs:
assert s.startswith("Product/"), f"unexpected slug shape: {s!r}"
# Spot-check the one we know is in the captured snapshot.
assert "Product/meat_seafood" in slugs
def test_parse_category_response_fixture() -> None:
"""Parser returns >=10 product dicts each with the mapped fields populated."""
if not CATEGORY_JSON.exists():
pytest.skip(f"fixture missing: {CATEGORY_JSON}")
payload = json.loads(CATEGORY_JSON.read_text(encoding="utf-8"))
raw_items = LuckyCaliforniaScraper.parse_category_response(payload)
assert len(raw_items) >= 10, f"expected >=10 raw items, got {len(raw_items)}"
mapped: list[dict] = []
for raw in raw_items:
m = LuckyCaliforniaScraper.map_product(
raw, aisle="meat_seafood", source_slug="Product/meat_seafood"
)
if m is not None:
mapped.append(m)
assert len(mapped) >= 10, (
f"expected >=10 mapped products, got {len(mapped)} "
f"(from {len(raw_items)} raw)"
)
sample = mapped[0]
# Required fields per the field-mapping contract.
for key in (
"external_id",
"source",
"name",
"current_price",
"regular_price",
"is_on_sale",
"image_url",
"aisle",
):
assert key in sample, f"missing key {key!r} in mapped product: {sample!r}"
assert sample["source"] == "lucky_california"
assert sample["aisle"] == "meat_seafood"
assert isinstance(sample["external_id"], str) and sample["external_id"]
assert isinstance(sample["name"], str) and sample["name"].strip()
assert isinstance(sample["regular_price"], Decimal)
assert sample["regular_price"] > 0
assert isinstance(sample["is_on_sale"], bool)
# Across the whole category at least SOME items should be on sale and
# at least some should have a regular-only price (sanity for the parser).
assert any(m["is_on_sale"] for m in mapped), "expected at least one sale item"
assert any(not m["is_on_sale"] for m in mapped), "expected at least one reg-only item"
def test_map_product_returns_none_for_unparseable() -> None:
"""Products with no name AND no parseable price are dropped."""
assert LuckyCaliforniaScraper.map_product({"name": ""}) is None
assert (
LuckyCaliforniaScraper.map_product(
{"id": "x", "name": "Foo", "price": {"ok": {}}}
)
is None
)
# ---------------------------------------------------------------------------
# 401 → SwiftlyAuthError → FAILED ScrapeLog
# ---------------------------------------------------------------------------
def _mock_response(status_code: int, payload=None) -> MagicMock:
resp = MagicMock(spec=requests.Response)
resp.status_code = status_code
if payload is not None:
resp.json.return_value = payload
if status_code >= 400:
resp.raise_for_status.side_effect = requests.HTTPError(
f"{status_code} error", response=resp
)
else:
resp.raise_for_status.return_value = None
return resp
def test_swiftly_auth_error_on_401_from_api() -> None:
"""A 401 from the API host raises SwiftlyAuthError before raise_for_status."""
scraper = LuckyCaliforniaScraper(bearer_token="stale-token")
with patch.object(scraper.api_session, "get", return_value=_mock_response(401)):
with pytest.raises(SwiftlyAuthError) as excinfo:
scraper.fetch_category("Product/meat_seafood")
assert "SWIFTLY_BEARER_TOKEN expired" in str(excinfo.value)
def test_swiftly_auth_error_when_token_missing() -> None:
"""An empty token short-circuits to SwiftlyAuthError without any HTTP call.
Force the token empty AFTER construction so the test is independent of
whatever ``SWIFTLY_BEARER_TOKEN`` happens to be set in the environment
(it WILL be set when pytest runs inside ``docker compose``).
"""
scraper = LuckyCaliforniaScraper()
scraper.bearer_token = ""
with patch.object(scraper.api_session, "get") as mock_get:
with pytest.raises(SwiftlyAuthError):
scraper.fetch_category("Product/meat_seafood")
mock_get.assert_not_called()
@pytest.mark.requires_postgres
def test_background_runner_writes_failed_with_token_message(monkeypatch):
"""A 401 during the background scrape lands in ScrapeLog as FAILED + message.
Uses a real (non-fixture) session so the bg runner's rollback+re-query
path mirrors production. The bg runner commits the FAILED row; we clean
up explicitly at the end.
"""
from app.models import ScrapeLog, ScrapeStatus
from app.services import scraper_service
from app.scraper.lucky_ca_scraper import SwiftlyAuthError, LuckyCaliforniaScraper
from app.database import SessionLocal
from datetime import datetime, timezone
log_id = uuid.uuid4()
setup_session = SessionLocal()
try:
setup_session.add(
ScrapeLog(
id=log_id,
source="lucky_california",
scrape_type="weekly_ad",
status=ScrapeStatus.STARTED,
started_at=datetime.now(timezone.utc),
)
)
setup_session.commit()
finally:
setup_session.close()
def _explode(self):
raise SwiftlyAuthError(
"SWIFTLY_BEARER_TOKEN expired — request a fresh token from the user "
"(capture from luckysupermarkets.com network tab on a /search/api/v1 request)"
)
monkeypatch.setattr(LuckyCaliforniaScraper, "fetch_all", _explode)
try:
scraper_service._run_scrape_in_background(
log_id, "lucky_california", "weekly_ad"
)
verify_session = SessionLocal()
try:
refreshed = (
verify_session.query(ScrapeLog)
.filter(ScrapeLog.id == log_id)
.first()
)
assert refreshed is not None
assert refreshed.status == ScrapeStatus.FAILED
assert "SWIFTLY_BEARER_TOKEN expired" in (refreshed.error_message or "")
assert refreshed.completed_at is not None
finally:
verify_session.close()
finally:
cleanup_session = SessionLocal()
try:
cleanup_session.query(ScrapeLog).filter(ScrapeLog.id == log_id).delete()
cleanup_session.commit()
finally:
cleanup_session.close()