Public Access
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>
233 lines
8.3 KiB
Python
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()
|