"""#3264: свип DomClick (`domclick_city_sweep`) ходил на bff-search-web.domclick.ru БЕЗ кук сессии и был обязан решать QRATOR PoW с нуля на каждый запрос — прод 30.08 (прогон 5330): 9/9 запросов зависли на challenge, 0 листингов. `domclick_detail_backfill` (добор карточек) уже решал эту задачу инъекцией снимка `domclick_session.load_session` — этот файл проверяет, что тот же снимок теперь доезжает и до свипа: 1. DomClickScraper(cookies=...) кладёт снимок в self._cookies и пробрасывает его в ОБА fetcher.fetch() (count-probe и paginate-leaf). 2. run_domclick_city_sweep(cookies=...) пробрасывает их в DomClickScraper(...). 3. product_handlers._job_domclick_city_sweep (app-side override kit-native, см. его докстринг) грузит куки через domclick_session.load_session(db) и не падает, когда сессии в БД нет/протухла (load_session вернул None) — свип в этом случае работает как раньше, без инъекции. Не проверяется (вне периметра): реальный обход QRATOR, browser_fetcher.fetch() внутренности (уже покрыты test_domclick_detail.py), ротация прокси/корзин (#2854/#2670). """ from __future__ import annotations import os from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import pytest from scraper_kit.providers.domclick.serp import DomClickScraper PFX = "scraper_kit.orchestration.pipeline" def _config() -> SimpleNamespace: return SimpleNamespace( scraper_fetch_mode="curl_cffi", browser_http_endpoint="http://browser.test/fetch", scraper_proxy_url=None, avito_proxy_max_rotations=0, avito_serp_ok_not_banned=True, avito_proxy_rotate_settle_s=0.0, proxy_rotate_attempts=1, proxy_rotate_attempt_timeout_s=1.0, cian_proxy_max_rotations=0, yandex_proxy_max_rotations=0, scraper_skip_seen_today=False, cian_full_load_per_fetch_timeout_s=0.0, ) # ── 1. DomClickScraper: cookies → self._cookies → оба fetcher.fetch() ──────────────── def test_domclick_scraper_stores_cookies() -> None: snapshot = {"sbjs": "x", "dvc": "y"} scraper = DomClickScraper(_config(), cookies=snapshot) assert scraper._cookies == snapshot def test_domclick_scraper_defaults_cookies_to_none() -> None: scraper = DomClickScraper(_config()) assert scraper._cookies is None @pytest.mark.asyncio async def test_count_passes_cookies_to_fetch() -> None: snapshot = {"sbjs": "x"} scraper = DomClickScraper(_config(), cookies=snapshot) fetcher = MagicMock() fetcher.fetch = AsyncMock(return_value='{"result": {"snippetsCount": 3}}') n = await scraper._count(fetcher, "st", None, None) assert n == 3 assert fetcher.fetch.await_args.kwargs["cookies"] == snapshot @pytest.mark.asyncio async def test_count_passes_none_cookies_without_error() -> None: """Cookies отсутствуют/протухли — не авария, fetch() зовётся с cookies=None.""" scraper = DomClickScraper(_config(), cookies=None) fetcher = MagicMock() fetcher.fetch = AsyncMock(return_value='{"result": {"snippetsCount": 0}}') await scraper._count(fetcher, "st", None, None) assert fetcher.fetch.await_args.kwargs["cookies"] is None @pytest.mark.asyncio async def test_paginate_passes_cookies_to_fetch() -> None: snapshot = {"sbjs": "x"} scraper = DomClickScraper(_config(), cookies=snapshot) fetcher = MagicMock() fetcher.fetch = AsyncMock(return_value='{"result": {"items": []}}') out: list[object] = [] await scraper._paginate(fetcher, "st", None, None, set(), out, pages=1) assert fetcher.fetch.await_args.kwargs["cookies"] == snapshot # ── 2. run_domclick_city_sweep: cookies → DomClickScraper(...) ─────────────────────── def _stub_runs() -> MagicMock: """runs-двойник: is_cancelled=False (иначе sweep выходит ДО создания scraper'а).""" m = MagicMock() m.is_cancelled = MagicMock(return_value=False) return m @pytest.mark.asyncio async def test_run_domclick_city_sweep_passes_cookies_to_scraper() -> None: from scraper_kit.orchestration.pipeline import run_domclick_city_sweep from scraper_kit.providers.domclick.serp import ROOM_BUCKETS snapshot = {"sbjs": "x"} scraper = MagicMock() scraper.__aenter__ = AsyncMock(return_value=scraper) scraper.__aexit__ = AsyncMock(return_value=None) scraper.fetch_city = AsyncMock(return_value=[]) scraper.blocked = False scraper.geo_filtered = 0 scraper.fetch_errors = 0 scraper.buckets_completed = len(ROOM_BUCKETS) scraper.buckets_total = len(ROOM_BUCKETS) ctor = MagicMock(return_value=scraper) db = MagicMock() with ( patch(f"{PFX}.DomClickScraper", ctor), patch(f"{PFX}.save_listings", MagicMock(return_value=(0, 0))), patch(f"{PFX}.runs", _stub_runs()), ): await run_domclick_city_sweep( db, config=_config(), matcher=MagicMock(), run_id=1, city_id=4, pages=1, request_delay_sec=0.0, cookies=snapshot, ) assert ctor.call_args.kwargs["cookies"] == snapshot @pytest.mark.asyncio async def test_run_domclick_city_sweep_none_cookies_does_not_break() -> None: """cookies=None (дефолт) — прежнее поведение, свип не падает.""" from scraper_kit.orchestration.pipeline import run_domclick_city_sweep from scraper_kit.providers.domclick.serp import ROOM_BUCKETS scraper = MagicMock() scraper.__aenter__ = AsyncMock(return_value=scraper) scraper.__aexit__ = AsyncMock(return_value=None) scraper.fetch_city = AsyncMock(return_value=[]) scraper.blocked = False scraper.geo_filtered = 0 scraper.fetch_errors = 0 scraper.buckets_completed = len(ROOM_BUCKETS) scraper.buckets_total = len(ROOM_BUCKETS) ctor = MagicMock(return_value=scraper) db = MagicMock() with ( patch(f"{PFX}.DomClickScraper", ctor), patch(f"{PFX}.save_listings", MagicMock(return_value=(0, 0))), patch(f"{PFX}.runs", _stub_runs()), ): counters = await run_domclick_city_sweep( db, config=_config(), matcher=MagicMock(), run_id=1, city_id=4, pages=1, request_delay_sec=0.0, ) assert ctor.call_args.kwargs["cookies"] is None assert counters.errors_count == 0 # ── 3. product_handlers._job_domclick_city_sweep: БД → cookies → sweep ─────────────── @pytest.mark.asyncio async def test_job_domclick_city_sweep_loads_and_passes_cookies() -> None: from app.services.product_handlers import _job_domclick_city_sweep snapshot = {"sbjs": "x"} db = MagicMock() ctx = MagicMock(proxy_provider=None, shutdown_requested=lambda: False) with ( patch( "app.services.domclick_session.load_session", MagicMock(return_value=snapshot), ), patch( "scraper_kit.orchestration.pipeline.run_domclick_city_sweep", AsyncMock(), ) as run_mock, patch("app.services.product_handlers.kit_pick_resume", MagicMock(return_value=None)), ): await _job_domclick_city_sweep(db, 1, {}, ctx) assert run_mock.await_args.kwargs["cookies"] == snapshot @pytest.mark.asyncio async def test_job_domclick_city_sweep_none_session_is_not_fatal() -> None: """load_session вернул None (нет/протухла) — свип идёт БЕЗ инъекции, не падает.""" from app.services.product_handlers import _job_domclick_city_sweep db = MagicMock() ctx = MagicMock(proxy_provider=None, shutdown_requested=lambda: False) with ( patch("app.services.domclick_session.load_session", MagicMock(return_value=None)), patch( "scraper_kit.orchestration.pipeline.run_domclick_city_sweep", AsyncMock(), ) as run_mock, patch("app.services.product_handlers.kit_pick_resume", MagicMock(return_value=None)), ): await _job_domclick_city_sweep(db, 1, {}, ctx) assert run_mock.await_args.kwargs["cookies"] is None def test_domclick_city_sweep_registered_as_product_override() -> None: """build_product_handlers() переопределяет kit-native domclick_city_sweep (#3264).""" from app.services.product_handlers import _job_domclick_city_sweep, build_product_handlers handlers = build_product_handlers(ctx=None) # type: ignore[arg-type] assert handlers["domclick_city_sweep"].job is _job_domclick_city_sweep # ── 4. Дрейф override'а от kit-native (#3264) ──────────────────────────────────────── @pytest.mark.asyncio async def test_product_override_matches_kit_native_call_except_cookies() -> None: """Продуктовый override зовёт sweep ТЕМИ ЖЕ аргументами, что kit-native, плюс cookies. Override (app.services.product_handlers) не оборачивает kit-джобу, а повторяет её вызов целиком — иначе пришлось бы тащить чтение БД внутрь kit, что запрещено strangler-инвариантом #2133. Цена такого решения — дрейф: параметр, добавленный в kit-native `_job_domclick_city_sweep`, в override не появится, и свип по расписанию молча поедет со старым набором. Этот тест — единственное, что такой дрейф ловит: оба джоба зовутся с одинаковыми db/run_id/params/ctx, и наборы kwargs сравниваются. Расхождение допускается ровно одно — `cookies`, ради которого override и заведён. """ from scraper_kit.orchestration.scheduler import ( _job_domclick_city_sweep as kit_job, ) from app.services.product_handlers import _job_domclick_city_sweep as product_job params = {"city_id": 4, "pages_per_anchor": 7, "request_delay_sec": 3.0} # Патчить sweep надо В ПРОСТРАНСTВЕ ИМЁН ВЫЗЫВАЮЩЕГО: kit-джоба связала имя # `run_domclick_city_sweep` на импорте модуля, продуктовая импортирует его внутри # тела. Патч по одному только `pipeline` kit-джобу не перехватит — она позовёт # настоящий свип и уйдёт в сеть. # db и ctx общие на оба захвата: иначе сравнение спотыкается не о дрейф, а о то, # что ctx.config — разные MagicMock-объекты в двух вызовах. db = MagicMock() ctx = MagicMock(proxy_provider=None, shutdown_requested=lambda: False) async def _capture(job, patch_sweep_in: str, patch_resume_in: str) -> dict: with ( patch("app.services.domclick_session.load_session", MagicMock(return_value=None)), patch(patch_sweep_in, AsyncMock()) as run_mock, patch(patch_resume_in, MagicMock(return_value=None)), ): await job(db, 1, params, ctx) return run_mock.await_args.kwargs kit_kwargs = await _capture( kit_job, "scraper_kit.orchestration.scheduler.run_domclick_city_sweep", "scraper_kit.orchestration.scheduler._pick_resume", ) product_kwargs = await _capture( product_job, f"{PFX}.run_domclick_city_sweep", "app.services.product_handlers.kit_pick_resume", ) assert set(product_kwargs) - set(kit_kwargs) == {"cookies"}, ( "override приобрёл аргументы сверх kit-native помимо cookies" ) assert not set(kit_kwargs) - set(product_kwargs), ( "kit-native получил аргумент, которого нет в override — свип по расписанию " "поедет без него (дрейф #3264)" ) for key, kit_value in kit_kwargs.items(): assert product_kwargs[key] == kit_value, f"override разошёлся с kit-native по {key!r}"