Files
adminandClaude Opus 4.7 dfd79a9d08 feat: AM-2 wire swiftly_auth.get_token() into LuckyCaliforniaScraper
fetch_category() now calls swiftly_auth.get_token() to mint a fresh
Firebase JWT on demand when no explicit bearer_token override is
pinned by tests. The cache short-circuit means the per-call mint
overhead is ~zero in the steady state.

- Removed the empty-token short-circuit; auto-mint makes it moot
- Updated _AUTH_ERROR_MESSAGE: 401-after-mint now points at the spec
  (Lucky tightening anon-auth) rather than asking for manual capture
- Replaced test_swiftly_auth_error_when_token_missing with a positive
  test that verifies fetch_category mints when bearer_token is None
- bearer_token constructor arg preserved for the 401-path test

Full suite: 92/92 green. Live verification via
scripts/spike_swiftly_ingest.py --confirm-live deferred to next step
per HANDOFF AM-2 halt boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:27:37 -07:00

233 lines
8.3 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 returned 401" in str(excinfo.value)
def test_fetch_category_mints_token_when_none_pinned() -> None:
"""With no explicit bearer_token, fetch_category calls swiftly_auth.get_token()
and threads the minted JWT into the Authorization header.
"""
from app.scraper import lucky_ca_scraper as scraper_mod
scraper = LuckyCaliforniaScraper()
assert scraper.bearer_token is None # auto-mint path
with patch.object(scraper_mod, "_get_swiftly_token", return_value="minted-jwt") as mtoken, \
patch.object(
scraper.api_session,
"get",
return_value=_mock_response(
200, payload={"products": {"items": []}}
),
) as mget:
scraper.fetch_category("Product/meat_seafood")
mtoken.assert_called_once()
sent_headers = mget.call_args.kwargs["headers"]
assert sent_headers["Authorization"] == "Bearer minted-jwt"
@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()