"""Tests for scraper proxy wiring (#806 + #2616 шаг 2 cleanup). Covers: - scraper_proxy_url property: reads SCRAPER_PROXY_URL only. Legacy per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL fallback removed (#2616 шаг 2 — dead mobileproxy accounts, 407/connection refused, #2613). - None/empty → direct connection (no proxies dict) - _avito_proxies() helper returns correct dict shape - per-scraper proxy wiring: YandexRealty, YandexValuation (kit), CianValuation, CianNewbuilding (fetch_newbuilding — now BrowserFetcher, proxy is browser-level), AvitoDetail Легаси `app.services.scrapers.yandex_valuation` / `avito_imv` удалены (#2277 финальный шаг scraper_kit-миграции). YandexValuation proxy-wiring тесты переведены на kit-эквивалент (`config=` DI вместо патча `app.core.config.settings`). `test_avito_imv_own_session_receives_proxies` (легаси, patch-based) удалён — эквивалентная и более точная kit-регрессия (config footgun: с/без `config=`) уже покрыта в `tests/scrapers/test_avito_imv_kit_parity.py:: test_kit_evaluate_via_imv_{with,without}_config_{uses,drops}_proxy`. Легаси `app.services.scrapers.cian_newbuilding` / `avito_detail` удалены (#2397 финальный шаг E, 0 runtime-импортёров) — CianNewbuilding/AvitoDetail proxy-wiring тесты переведены на kit-эквиваленты (`scraper_kit.providers.cian.newbuilding` / `scraper_kit.providers.avito.detail`). Kit `cian_newbuilding` больше не импортирует `AsyncSession` на уровне модуля рантайма (только `TYPE_CHECKING` — страница ЖК тянется исключительно через BrowserFetcher), поэтому patch.object(..., "AsyncSession", ...) заменён на `hasattr`-проверку отсутствия атрибута (та же гарантия: page-fetch НЕ использует curl_cffi AsyncSession). """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest def _mock_settings( scraper_proxy_url: str | None, yandex_cookies_file: str | None = None, ) -> SimpleNamespace: """Minimal settings stand-in with fields read by proxy helpers and scraper __aenter__.""" return SimpleNamespace( scraper_proxy_url=scraper_proxy_url, # cian_proxy_url / yandex_proxy_url properties now mirror scraper_proxy_url # 1:1 (#2616 шаг 2: per-provider CIAN_PROXY_URL/YANDEX_PROXY_URL override # removed — dead mobileproxy accounts). Mirrored here for test compat. cian_proxy_url=scraper_proxy_url, yandex_proxy_url=scraper_proxy_url, yandex_cookies_file=yandex_cookies_file, ) # ── Settings.scraper_proxy_url property ────────────────────────────────────── # Test the property logic directly via inline replica — avoids instantiating # pydantic-settings (requires DATABASE_URL) for pure unit tests of the rule. def _resolve_scraper_proxy_url(scraper_proxy_url_env: str | None) -> str | None: """Inline replica of Settings.scraper_proxy_url property logic (#2616 шаг 2: legacy AVITO_PROXY_URL fallback removed — SCRAPER_PROXY_URL is now the sole source, projected as-is).""" return scraper_proxy_url_env def test_scraper_proxy_url_uses_scraper_env_when_set(): result = _resolve_scraper_proxy_url(scraper_proxy_url_env="http://scraper-proxy:1234") assert result == "http://scraper-proxy:1234" def test_scraper_proxy_url_none_when_absent(): result = _resolve_scraper_proxy_url(scraper_proxy_url_env=None) assert result is None # ── _avito_proxies() dict shape ─────────────────────────────────────────────── def test_avito_proxies_returns_correct_dict_shape(): """Kit `_avito_proxies(config)` — DI параметр вместо module-level settings patch.""" from scraper_kit.orchestration.pipeline import _avito_proxies proxy_url = "http://user:pass@proxy.example.com:8080" result = _avito_proxies(_mock_settings(proxy_url)) assert result == {"http": proxy_url, "https": proxy_url} def test_avito_proxies_returns_none_when_no_proxy(): from scraper_kit.orchestration.pipeline import _avito_proxies result = _avito_proxies(_mock_settings(None)) assert result is None # ── Real pydantic env-binding (regression for #806 fixup) ───────────────────── # The inline-replica tests above prove the property logic but NOT that the field # actually binds to env SCRAPER_PROXY_URL. `validation_alias` is what makes that # work — without it pydantic-settings reads SCRAPER_PROXY_URL_ENV (the field # name), so setting SCRAPER_PROXY_URL would be a silent no-op. These tests # construct a real Settings and assert end-to-end env binding. def _fresh_settings(): from app.core.config import Settings return Settings(_env_file=None) def test_settings_binds_scraper_proxy_url_env(monkeypatch): monkeypatch.setenv("SCRAPER_PROXY_URL", "http://scraper-proxy:1234") assert _fresh_settings().scraper_proxy_url == "http://scraper-proxy:1234" def test_settings_scraper_proxy_url_none_when_unset(monkeypatch): monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) assert _fresh_settings().scraper_proxy_url is None def test_settings_ignores_misnamed_scraper_proxy_url_env(monkeypatch): """Regression: the old wrong env name SCRAPER_PROXY_URL_ENV must NOT bind.""" monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) monkeypatch.setenv("SCRAPER_PROXY_URL_ENV", "http://wrong-name:1111") assert _fresh_settings().scraper_proxy_url is None # ── Per-scraper proxy wiring (follow-up: yandex/valuation/imv/newbuilding) ──── # Each test: patch settings.scraper_proxy_url, mock AsyncSession constructor, # invoke __aenter__ / create-session path, assert proxies= kwarg is forwarded. # proxy=None path: assert proxies kwarg is None / not passed as non-None. _PROXY_URL = "http://mobile.proxy:8080" _EXPECTED_PROXIES = {"http": _PROXY_URL, "https": _PROXY_URL} # ── YandexRealtyScraper ────────────────────────────────────────────────────── # # `test_yandex_realty_opens_browser_fetcher` (legacy `app.services.scrapers. # yandex_realty.YandexRealtyScraper.__aenter__` opens ONE BrowserFetcher session) # removed (#2397 Part E2) — `yandex_realty` deleted, 0 runtime importers. Kit # equivalent already covered (opens exactly one BrowserFetcher session via # `build_browser_fetcher`) by `test_kit_serp_proxy_pool.py:: # test_yandex_serp_no_pool_by_default` (and the pool-wiring variants alongside it). # ── YandexValuationScraper ─────────────────────────────────────────────────── @pytest.mark.asyncio async def test_yandex_valuation_session_receives_proxies(): """Kit YandexValuationScraper.__aenter__ forwards proxies= kwarg from config.""" from scraper_kit.providers.yandex.valuation import YandexValuationScraper mock_session = MagicMock() mock_session.close = AsyncMock() captured_kwargs: dict = {} def _fake_session(*args, **kwargs): captured_kwargs.update(kwargs) return mock_session cfg = SimpleNamespace(use_proxy_pool_curl=False, scraper_proxy_url=_PROXY_URL) with patch("scraper_kit.providers.yandex.valuation._CurlCffiSession", _fake_session): async with YandexValuationScraper(cfg): pass assert captured_kwargs.get("proxies") == _EXPECTED_PROXIES @pytest.mark.asyncio async def test_yandex_valuation_session_no_proxies_when_none(): """Kit YandexValuationScraper.__aenter__: proxies=None when no proxy configured.""" from scraper_kit.providers.yandex.valuation import YandexValuationScraper mock_session = MagicMock() mock_session.close = AsyncMock() captured_kwargs: dict = {} def _fake_session(*args, **kwargs): captured_kwargs.update(kwargs) return mock_session cfg = SimpleNamespace(use_proxy_pool_curl=False, scraper_proxy_url=None) with patch("scraper_kit.providers.yandex.valuation._CurlCffiSession", _fake_session): async with YandexValuationScraper(cfg): pass assert captured_kwargs.get("proxies") is None # ── CianNewbuilding — fetch_newbuilding ─────────────────────────────────────── # После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox), # а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера, # а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся. @pytest.mark.asyncio async def test_cian_newbuilding_own_session_receives_proxies(): """fetch_newbuilding использует BrowserFetcher для page-fetch, не AsyncSession. Проверяем: - BrowserFetcher.fetch(url) вызывается с правильным URL - curl_cffi AsyncSession НЕ создаётся для page-fetch (прокси — browser-level; kit-модуль вообще не импортирует AsyncSession на уровне рантайма) """ from scraper_kit.providers.cian import newbuilding as cian_newbuilding zhk_url = "https://zhk-test.cian.ru/" fetch_calls: list[str] = [] mock_fetcher = MagicMock() mock_fetcher.fetch = AsyncMock( side_effect=lambda url: fetch_calls.append(url) or "" ) mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher) mock_fetcher.__aexit__ = AsyncMock(return_value=None) with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher): await cian_newbuilding.fetch_newbuilding(zhk_url) # BrowserFetcher.fetch вызван с ЖК-url assert fetch_calls == [zhk_url] # curl_cffi AsyncSession НЕ создавался для page-fetch (нет такого атрибута на # уровне рантайма — только BrowserFetcher). assert not hasattr(cian_newbuilding, "AsyncSession") @pytest.mark.asyncio async def test_cian_newbuilding_shared_session_not_recreated(): """fetch_newbuilding принимает session= для backward-compat, но использует BrowserFetcher. Даже когда передан внешний session — страница ЖК тянется через BrowserFetcher, AsyncSession не пересоздаётся, session.get не вызывается. """ from scraper_kit.providers.cian import newbuilding as cian_newbuilding zhk_url = "https://zhk-test.cian.ru/" fetch_calls: list[str] = [] mock_fetcher = MagicMock() mock_fetcher.fetch = AsyncMock( side_effect=lambda url: fetch_calls.append(url) or "" ) mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher) mock_fetcher.__aexit__ = AsyncMock(return_value=None) sentinel_session = MagicMock(name="external_session") with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher): await cian_newbuilding.fetch_newbuilding(zhk_url, session=sentinel_session) # BrowserFetcher.fetch вызван — именно через него тянется страница assert fetch_calls == [zhk_url] # Ни нового AsyncSession (атрибута вообще нет на рантайме), ни вызова переданного # session.get — proxy browser-level assert not hasattr(cian_newbuilding, "AsyncSession") sentinel_session.get.assert_not_called() # ── AvitoDetail — fetch_detail own-session ─────────────────────────────────── @pytest.mark.asyncio async def test_avito_detail_own_session_receives_proxies(): """fetch_detail creates own session with proxies= when no cffi_session passed.""" from scraper_kit.providers.avito import detail as avito_detail mock_session = MagicMock() mock_resp = MagicMock() mock_resp.status_code = 200 # Minimal HTML that parse_detail_html won't raise ValueError (item_id required) mock_resp.text = "" mock_session.get = AsyncMock(return_value=mock_resp) mock_session.close = AsyncMock() captured_kwargs: dict = {} def _fake_session(*args, **kwargs): captured_kwargs.update(kwargs) return mock_session # Kit _build_detail_session delegates to scraper_kit.providers._base.build_document_session # (shared helper), which constructs AsyncSession there — not in the detail module itself. # config= (strangler DI, #2133) replaces the old app.core.config.settings patch. cfg = SimpleNamespace(scraper_proxy_url=_PROXY_URL) with patch("scraper_kit.providers._base.AsyncSession", _fake_session): try: await avito_detail.fetch_detail("/ekaterinburg/kvartiry/test-1234", config=cfg) except ValueError: pass # parse_detail_html raises ValueError (no item_id) — that's ok assert captured_kwargs.get("proxies") == _EXPECTED_PROXIES @pytest.mark.asyncio async def test_avito_detail_shared_session_not_patched(): """fetch_detail does NOT build own session when cffi_session is provided.""" from scraper_kit.providers.avito import detail as avito_detail mock_session = MagicMock() mock_resp = MagicMock() mock_resp.status_code = 200 mock_resp.text = "" mock_session.get = AsyncMock(return_value=mock_resp) mock_session.close = AsyncMock() ctor_calls = [] def _fake_session(*args, **kwargs): ctor_calls.append(kwargs) return MagicMock() cfg = SimpleNamespace(scraper_proxy_url=_PROXY_URL) with patch("scraper_kit.providers._base.AsyncSession", _fake_session): try: await avito_detail.fetch_detail( "/ekaterinburg/kvartiry/test-1234", cffi_session=mock_session, config=cfg, ) except ValueError: pass # No own session created — shared session was used as-is assert ctor_calls == [] # ── Settings.cian_proxy_url property ───────────────────────────────────────── # #2616 шаг 2: dedicated CIAN_PROXY_URL removed (dead mobileproxy account, #2613) — # cian_proxy_url now mirrors scraper_proxy_url 1:1. Property kept (not inlined at # call sites) for scraper_kit.contracts.ScraperConfig Protocol compatibility. def _resolve_cian_proxy_url(scraper_proxy_url: str | None) -> str | None: """Inline replica of Settings.cian_proxy_url property logic (post #2616 шаг 2).""" return scraper_proxy_url def test_cian_proxy_url_mirrors_scraper_proxy_url(): assert _resolve_cian_proxy_url("http://shared-proxy:1111") == "http://shared-proxy:1111" def test_cian_proxy_url_none_when_absent(): assert _resolve_cian_proxy_url(None) is None def test_settings_cian_proxy_url_mirrors_scraper_proxy_url(monkeypatch): """cian_proxy_url tracks scraper_proxy_url 1:1 — no more independent CIAN_PROXY_URL.""" monkeypatch.setenv("SCRAPER_PROXY_URL", "http://shared-proxy:1111") s = _fresh_settings() assert s.cian_proxy_url == "http://shared-proxy:1111" assert s.cian_proxy_url == s.scraper_proxy_url