"""#2657: домклик собирал 0 лотов, потому что ходил мимо прокси-пула. Прод 06-09.08.2026: `domclick_city_sweep` четыре прогона подряд `failed` с `buckets=0/6, lots=0, errors=1`. Причина найдена не в QRATOR и не в куках, а в двух сложившихся дефектах: 1. **Домклик был единственным sweep-источником без `proxy_provider`** (#2160 P4 wiring gap). `build_browser_fetcher(config, "domclick")` без провайдера → lease не берётся → сайдкар идёт через статический `SCRAPER_PROXY_URL`. Этот адрес (прод `scrape_proxies.id=1`, residential, `provider_affinity='domclick'`) с 06.08 отдаёт `NS_ERROR_PROXY_BAD_GATEWAY` ИМЕННО на `bff-search-web.domclick.ru`. Живая проверка 09.08 через сайдкар: тот же URL через id=1 → 500, через мобильный узел пула (`id=10`) → 200 и `{"offersCount":704,"snippetsCount":678}` в бакете `st`. Предпосылка миграции 173 («QRATOR банит все прокси кроме этого residential») опровергнута: мобильный узел домкликовский BFF пускает. 2. **Транспортная ошибка убивала весь свип на первом бакете.** `fetch_city` ловил только `ValueError/TypeError` — ошибки РАЗБОРА. `httpx.HTTPStatusError` от сайдкара пролетал наружу мимо цикла по `ROOM_BUCKETS`, и прогон заканчивался с `0/6`. Это же не давало сработать ротации lease после N подряд провалов (`browser_fetcher._LEASE_ROTATE_AFTER_FAILS`) — до неё не доживали. Фальсификация на старом коде: `test_scraper_leases_proxy_for_domclick` падает `TypeError: unexpected keyword argument 'proxy_provider'`, `test_transport_error_does_not_kill_the_whole_sweep` падает на пробросе `HTTPStatusError` наружу. """ from __future__ import annotations import os from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from scraper_kit.orchestration.scheduler import _job_domclick_city_sweep from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper def _config() -> SimpleNamespace: return SimpleNamespace( browser_http_endpoint="http://x:9000", use_proxy_pool_browser=True, environment="production", ) @pytest.fixture def _no_browser() -> Any: """Сайдкар не поднимается — нас интересует только то, ЧТО ему передали.""" fetcher = MagicMock() fetcher.__aenter__ = AsyncMock(return_value=fetcher) fetcher.__aexit__ = AsyncMock(return_value=None) with patch("scraper_kit.providers._base.BrowserFetcher", return_value=fetcher) as bf: yield bf async def test_scraper_leases_proxy_for_domclick(_no_browser: MagicMock) -> None: """Узел берётся из пула под именем "domclick" — а не из env сайдкара.""" provider = MagicMock() scraper = DomClickScraper(_config(), proxy_provider=provider) with patch.object(DomClickScraper, "_sweep_bucket", AsyncMock(return_value=None)): await scraper.fetch_city(city_id=4) assert _no_browser.call_args.kwargs["proxy_provider"] is provider assert _no_browser.call_args.kwargs["use_pool"] is True async def test_transport_error_does_not_kill_the_whole_sweep(_no_browser: MagicMock) -> None: """Отказ сайдкара на первом бакете не обнуляет прогон — остальные бакеты идут. Прод-форма отказа: 500 от tradein-browser с NS_ERROR_PROXY_BAD_GATEWAY в теле. """ scraper = DomClickScraper(_config()) calls = {"n": 0} async def _sweep(self: DomClickScraper, **_: object) -> None: calls["n"] += 1 if calls["n"] == 1: raise httpx.HTTPStatusError( "Server error '500 Internal Server Error' | tradein-browser: " '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}', request=MagicMock(), response=MagicMock(), ) with patch.object(DomClickScraper, "_sweep_bucket", _sweep): await scraper.fetch_city(city_id=4) assert calls["n"] == len(ROOM_BUCKETS) # Битый бакет НЕ засчитан в охват (#2670) — прогон честно неполон. assert scraper.buckets_completed == len(ROOM_BUCKETS) - 1 assert scraper.fetch_errors == 1 async def test_scheduler_passes_pool_to_domclick_job() -> None: """Планировщик отдаёт домклику ctx.proxy_provider — как всем остальным свипам.""" provider = MagicMock() ctx = SimpleNamespace( config=_config(), matcher=MagicMock(), shutdown_requested=lambda: False, proxy_provider=provider, ) sweep = AsyncMock(return_value=None) with patch("scraper_kit.orchestration.scheduler.run_domclick_city_sweep", sweep): await _job_domclick_city_sweep(MagicMock(), 1, {}, ctx) # type: ignore[arg-type] assert sweep.await_args.kwargs["proxy_provider"] is provider