Public Access
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>
This commit is contained in:
@@ -36,6 +36,10 @@ import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.config import settings
|
||||
from app.services.swiftly_auth import (
|
||||
SwiftlyAuthMintError,
|
||||
get_token as _get_swiftly_token,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,8 +57,8 @@ _USER_AGENT = (
|
||||
)
|
||||
|
||||
_AUTH_ERROR_MESSAGE = (
|
||||
"SWIFTLY_BEARER_TOKEN expired — request a fresh token from the user "
|
||||
"(capture from luckysupermarkets.com network tab on a /search/api/v1 request)"
|
||||
"Swiftly returned 401 with a freshly minted Firebase JWT — Lucky may have "
|
||||
"tightened anon-auth restrictions. See docs/specs/2026-05-06-swiftly-token-auto-mint.md."
|
||||
)
|
||||
|
||||
|
||||
@@ -87,7 +91,10 @@ class LuckyCaliforniaScraper:
|
||||
rate_limit_seconds: float = 0.75,
|
||||
timeout: int = 60,
|
||||
) -> None:
|
||||
self.bearer_token = bearer_token or settings.SWIFTLY_BEARER_TOKEN
|
||||
# When ``bearer_token`` is None, fetch_category() mints via Firebase
|
||||
# REST through ``swiftly_auth.get_token()``. Tests pin a fixed token
|
||||
# to exercise the 401 path without going through Firebase.
|
||||
self.bearer_token = bearer_token
|
||||
self.store_id = store_id or settings.LUCKY_STORE_ID
|
||||
self.api_base = (api_base or settings.SWIFTLY_API_BASE).rstrip("/")
|
||||
self.categories_url = categories_url or settings.SWIFTLY_CATEGORIES_URL
|
||||
@@ -198,13 +205,12 @@ class LuckyCaliforniaScraper:
|
||||
# ------------------------------------------------------------------
|
||||
def fetch_category(self, slug: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch every product in a category. Raises ``SwiftlyAuthError`` on 401."""
|
||||
if not self.bearer_token:
|
||||
raise SwiftlyAuthError(_AUTH_ERROR_MESSAGE)
|
||||
token = self.bearer_token if self.bearer_token else _get_swiftly_token()
|
||||
|
||||
self._rate_limit()
|
||||
url = f"{self.api_base}/search/api/v1/products/categories"
|
||||
params = {"cat": slug, "store": self.store_id, "limit": 10000}
|
||||
headers = {"Authorization": f"Bearer {self.bearer_token}"}
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
resp = self.api_session.get(
|
||||
url, params=params, headers=headers, timeout=self.timeout
|
||||
)
|
||||
|
||||
@@ -140,22 +140,31 @@ def test_swiftly_auth_error_on_401_from_api() -> None:
|
||||
with pytest.raises(SwiftlyAuthError) as excinfo:
|
||||
scraper.fetch_category("Product/meat_seafood")
|
||||
|
||||
assert "SWIFTLY_BEARER_TOKEN expired" in str(excinfo.value)
|
||||
assert "Swiftly returned 401" 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``).
|
||||
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()
|
||||
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()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user