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 bs4 import BeautifulSoup
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.services.swiftly_auth import (
|
||||||
|
SwiftlyAuthMintError,
|
||||||
|
get_token as _get_swiftly_token,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -53,8 +57,8 @@ _USER_AGENT = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_AUTH_ERROR_MESSAGE = (
|
_AUTH_ERROR_MESSAGE = (
|
||||||
"SWIFTLY_BEARER_TOKEN expired — request a fresh token from the user "
|
"Swiftly returned 401 with a freshly minted Firebase JWT — Lucky may have "
|
||||||
"(capture from luckysupermarkets.com network tab on a /search/api/v1 request)"
|
"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,
|
rate_limit_seconds: float = 0.75,
|
||||||
timeout: int = 60,
|
timeout: int = 60,
|
||||||
) -> None:
|
) -> 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.store_id = store_id or settings.LUCKY_STORE_ID
|
||||||
self.api_base = (api_base or settings.SWIFTLY_API_BASE).rstrip("/")
|
self.api_base = (api_base or settings.SWIFTLY_API_BASE).rstrip("/")
|
||||||
self.categories_url = categories_url or settings.SWIFTLY_CATEGORIES_URL
|
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]]:
|
def fetch_category(self, slug: str) -> List[Dict[str, Any]]:
|
||||||
"""Fetch every product in a category. Raises ``SwiftlyAuthError`` on 401."""
|
"""Fetch every product in a category. Raises ``SwiftlyAuthError`` on 401."""
|
||||||
if not self.bearer_token:
|
token = self.bearer_token if self.bearer_token else _get_swiftly_token()
|
||||||
raise SwiftlyAuthError(_AUTH_ERROR_MESSAGE)
|
|
||||||
|
|
||||||
self._rate_limit()
|
self._rate_limit()
|
||||||
url = f"{self.api_base}/search/api/v1/products/categories"
|
url = f"{self.api_base}/search/api/v1/products/categories"
|
||||||
params = {"cat": slug, "store": self.store_id, "limit": 10000}
|
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(
|
resp = self.api_session.get(
|
||||||
url, params=params, headers=headers, timeout=self.timeout
|
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:
|
with pytest.raises(SwiftlyAuthError) as excinfo:
|
||||||
scraper.fetch_category("Product/meat_seafood")
|
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:
|
def test_fetch_category_mints_token_when_none_pinned() -> None:
|
||||||
"""An empty token short-circuits to SwiftlyAuthError without any HTTP call.
|
"""With no explicit bearer_token, fetch_category calls swiftly_auth.get_token()
|
||||||
|
and threads the minted JWT into the Authorization header.
|
||||||
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``).
|
|
||||||
"""
|
"""
|
||||||
|
from app.scraper import lucky_ca_scraper as scraper_mod
|
||||||
|
|
||||||
scraper = LuckyCaliforniaScraper()
|
scraper = LuckyCaliforniaScraper()
|
||||||
scraper.bearer_token = ""
|
assert scraper.bearer_token is None # auto-mint path
|
||||||
with patch.object(scraper.api_session, "get") as mock_get:
|
|
||||||
with pytest.raises(SwiftlyAuthError):
|
with patch.object(scraper_mod, "_get_swiftly_token", return_value="minted-jwt") as mtoken, \
|
||||||
scraper.fetch_category("Product/meat_seafood")
|
patch.object(
|
||||||
mock_get.assert_not_called()
|
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
|
@pytest.mark.requires_postgres
|
||||||
|
|||||||
Reference in New Issue
Block a user