From dfd79a9d08b99cd73f08ac77ec7f54f4281608ac Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Wed, 6 May 2026 15:27:37 -0700 Subject: [PATCH] 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) --- backend/app/scraper/lucky_ca_scraper.py | 18 +++++++++----- backend/tests/test_swiftly_api.py | 33 ++++++++++++++++--------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/backend/app/scraper/lucky_ca_scraper.py b/backend/app/scraper/lucky_ca_scraper.py index 003d28d..66d332d 100644 --- a/backend/app/scraper/lucky_ca_scraper.py +++ b/backend/app/scraper/lucky_ca_scraper.py @@ -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 ) diff --git a/backend/tests/test_swiftly_api.py b/backend/tests/test_swiftly_api.py index 67c3d4d..7e5350b 100644 --- a/backend/tests/test_swiftly_api.py +++ b/backend/tests/test_swiftly_api.py @@ -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