"""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/` 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_BEARER_TOKEN expired" 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``). """ 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() @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()