gendesign/tradein-mvp/backend/tests/test_scraper_proxy.py
bot-backend a07fe55db9 fix(tradein): route yandex/valuation/imv/newbuilding scrapers through scraper_proxy_url
Yandex SERP was being banned (datacenter IP) and on-demand scrapers (cian_valuation,
cian_newbuilding, avito_imv, avito_detail) silently returned None due to same cause.
Mobile proxy was already wired for cian.py/cian_detail.py/avito.py but missing elsewhere.

- yandex_realty: proxies= added to _CurlCffiSession in __aenter__
- yandex_valuation: proxies= added to _CurlCffiSession in __aenter__
- cian_valuation: proxies= added to AsyncSession (async with ... fetch block)
- cian_newbuilding: proxies= added to both own-session paths (fetch_newbuilding +
  resolve_cian_zhk_url); shared-session callers unaffected
- avito_imv: proxies= added to own-session path only (shared CffiAsyncSession from
  AvitoScraper already carries proxy)
- avito_detail: proxies= added to own-session path only (scrape_pipeline passes shared
  session with proxy; admin endpoint creates own-session -> now proxied)

All use pattern: _proxy = settings.scraper_proxy_url; proxies={"http":_proxy,"https":_proxy}
if _proxy else None -- matching cian.py (#806 reference). proxy=None = direct connection (dev).

Tests: 9 new proxy-wiring unit tests added to test_scraper_proxy.py (20 total, all pass).
2026-05-31 09:57:17 +03:00

429 lines
16 KiB
Python

"""Tests for scraper proxy wiring (#806 + follow-up).
Covers:
- scraper_proxy_url property: SCRAPER_PROXY_URL takes precedence over AVITO_PROXY_URL
- AVITO_PROXY_URL fallback works when SCRAPER_PROXY_URL is absent
- None/empty → direct connection (no proxies dict)
- _avito_proxies() helper returns correct dict shape
- per-scraper proxy wiring: YandexRealty, YandexValuation, CianValuation,
CianNewbuilding (fetch_newbuilding + resolve_cian_zhk_url), AvitoIMV, AvitoDetail
"""
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,
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, avito_proxy_url: str | None
) -> str | None:
"""Inline replica of Settings.scraper_proxy_url property logic."""
return scraper_proxy_url_env or avito_proxy_url
def test_scraper_proxy_url_uses_scraper_env_when_set():
result = _resolve_scraper_proxy_url(
scraper_proxy_url_env="http://scraper-proxy:1234",
avito_proxy_url="http://avito-proxy:5678",
)
assert result == "http://scraper-proxy:1234"
def test_scraper_proxy_url_falls_back_to_avito_proxy_url():
"""AVITO_PROXY_URL used when SCRAPER_PROXY_URL absent — zero env change on prod."""
result = _resolve_scraper_proxy_url(
scraper_proxy_url_env=None,
avito_proxy_url="http://avito-proxy:5678",
)
assert result == "http://avito-proxy:5678"
def test_scraper_proxy_url_none_when_both_absent():
result = _resolve_scraper_proxy_url(scraper_proxy_url_env=None, avito_proxy_url=None)
assert result is None
def test_scraper_proxy_url_empty_string_falls_through_to_avito():
"""Empty string is falsy — falls through to avito_proxy_url."""
result = _resolve_scraper_proxy_url(
scraper_proxy_url_env="",
avito_proxy_url="http://avito-proxy:5678",
)
assert result == "http://avito-proxy:5678"
# ── _avito_proxies() dict shape ───────────────────────────────────────────────
def test_avito_proxies_returns_correct_dict_shape():
from app.services.scrape_pipeline import _avito_proxies
proxy_url = "http://user:pass@proxy.example.com:8080"
with patch("app.services.scrape_pipeline.settings", _mock_settings(proxy_url)):
result = _avito_proxies()
assert result == {"http": proxy_url, "https": proxy_url}
def test_avito_proxies_returns_none_when_no_proxy():
from app.services.scrape_pipeline import _avito_proxies
with patch("app.services.scrape_pipeline.settings", _mock_settings(None)):
result = _avito_proxies()
assert result is None
def test_avito_proxies_fallback_via_avito_proxy_url():
"""_avito_proxies() uses AVITO_PROXY_URL via scraper_proxy_url fallback.
When settings.scraper_proxy_url resolves to the avito URL (fallback path),
the dict shape must be {"http": url, "https": url}.
"""
from app.services.scrape_pipeline import _avito_proxies
avito_url = "http://mobile-proxy.space:9090"
with patch("app.services.scrape_pipeline.settings", _mock_settings(avito_url)):
result = _avito_proxies()
assert result == {"http": avito_url, "https": avito_url}
# ── Real pydantic env-binding (regression for #806 fixup) ─────────────────────
# The inline-replica tests above prove the OR-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")
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
assert _fresh_settings().scraper_proxy_url == "http://scraper-proxy:1234"
def test_settings_scraper_env_overrides_avito(monkeypatch):
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://scraper-proxy:1234")
monkeypatch.setenv("AVITO_PROXY_URL", "http://avito-proxy:5678")
assert _fresh_settings().scraper_proxy_url == "http://scraper-proxy:1234"
def test_settings_falls_back_to_avito_env(monkeypatch):
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
monkeypatch.setenv("AVITO_PROXY_URL", "http://avito-proxy:5678")
assert _fresh_settings().scraper_proxy_url == "http://avito-proxy:5678"
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.delenv("AVITO_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 ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_yandex_realty_session_receives_proxies(monkeypatch):
"""YandexRealtyScraper.__aenter__ forwards scraper_proxy_url as proxies= kwarg.
settings is imported deferred inside __aenter__ via `from app.core.config import settings`,
so we patch app.core.config.settings (module attribute) which the deferred import reads.
"""
from app.services.scrapers.yandex_realty import YandexRealtyScraper
mock_session = MagicMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.close = AsyncMock()
captured_kwargs: dict = {}
def _fake_session(*args, **kwargs):
captured_kwargs.update(kwargs)
return mock_session
monkeypatch.setattr("app.services.scrapers.yandex_realty._CurlCffiSession", _fake_session)
with patch("app.core.config.settings", _mock_settings(_PROXY_URL)):
scraper = YandexRealtyScraper()
await scraper.__aenter__()
assert captured_kwargs.get("proxies") == _EXPECTED_PROXIES
@pytest.mark.asyncio
async def test_yandex_realty_session_no_proxies_when_none(monkeypatch):
"""YandexRealtyScraper.__aenter__: proxies=None when scraper_proxy_url is None."""
from app.services.scrapers.yandex_realty import YandexRealtyScraper
mock_session = MagicMock()
mock_session.close = AsyncMock()
captured_kwargs: dict = {}
def _fake_session(*args, **kwargs):
captured_kwargs.update(kwargs)
return mock_session
monkeypatch.setattr("app.services.scrapers.yandex_realty._CurlCffiSession", _fake_session)
with patch("app.core.config.settings", _mock_settings(None)):
scraper = YandexRealtyScraper()
await scraper.__aenter__()
assert captured_kwargs.get("proxies") is None
# ── YandexValuationScraper ───────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_yandex_valuation_session_receives_proxies():
"""YandexValuationScraper.__aenter__ forwards proxies= kwarg."""
from app.services.scrapers.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
with patch("app.services.scrapers.yandex_valuation._CurlCffiSession", _fake_session):
with patch("app.core.config.settings", _mock_settings(_PROXY_URL)):
scraper = YandexValuationScraper()
await scraper.__aenter__()
assert captured_kwargs.get("proxies") == _EXPECTED_PROXIES
@pytest.mark.asyncio
async def test_yandex_valuation_session_no_proxies_when_none():
"""YandexValuationScraper.__aenter__: proxies=None when no proxy configured."""
from app.services.scrapers.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
with patch("app.services.scrapers.yandex_valuation._CurlCffiSession", _fake_session):
with patch("app.core.config.settings", _mock_settings(None)):
scraper = YandexValuationScraper()
await scraper.__aenter__()
assert captured_kwargs.get("proxies") is None
# ── CianNewbuilding — fetch_newbuilding ───────────────────────────────────────
@pytest.mark.asyncio
async def test_cian_newbuilding_own_session_receives_proxies():
"""fetch_newbuilding creates own session with proxies= when no session passed."""
from app.services.scrapers import cian_newbuilding
mock_session = MagicMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = "" # empty HTML → extract_state returns None → result None (ok)
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
with patch.object(cian_newbuilding, "AsyncSession", _fake_session):
with patch.object(cian_newbuilding, "settings", _mock_settings(_PROXY_URL)):
await cian_newbuilding.fetch_newbuilding("https://zhk-test.cian.ru/")
assert captured_kwargs.get("proxies") == _EXPECTED_PROXIES
@pytest.mark.asyncio
async def test_cian_newbuilding_shared_session_not_recreated():
"""fetch_newbuilding does NOT create own session when one is passed."""
from app.services.scrapers import cian_newbuilding
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()
session_ctor_calls = []
def _fake_session(*args, **kwargs):
session_ctor_calls.append(kwargs)
return MagicMock()
with patch.object(cian_newbuilding, "AsyncSession", _fake_session):
with patch.object(cian_newbuilding, "settings", _mock_settings(_PROXY_URL)):
await cian_newbuilding.fetch_newbuilding(
"https://zhk-test.cian.ru/", session=mock_session
)
# No own session should have been created (shared session was passed)
assert session_ctor_calls == []
# ── 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 app.services.scrapers import 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 = "<html><body></body></html>"
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
with patch.object(avito_detail, "AsyncSession", _fake_session):
with patch("app.core.config.settings", _mock_settings(_PROXY_URL)):
try:
await avito_detail.fetch_detail("/ekaterinburg/kvartiry/test-1234")
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 app.services.scrapers import avito_detail
mock_session = MagicMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = "<html><body></body></html>"
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()
with patch.object(avito_detail, "AsyncSession", _fake_session):
with patch("app.core.config.settings", _mock_settings(_PROXY_URL)):
try:
await avito_detail.fetch_detail(
"/ekaterinburg/kvartiry/test-1234",
cffi_session=mock_session,
)
except ValueError:
pass
# No own session created — shared session was used as-is
assert ctor_calls == []
# ── AvitoIMV — evaluate_via_imv own-session ──────────────────────────────────
@pytest.mark.asyncio
async def test_avito_imv_own_session_receives_proxies():
"""evaluate_via_imv creates own session with proxies= when none passed.
`CffiAsyncSession` is imported locally inside evaluate_via_imv as
`from curl_cffi.requests import AsyncSession as CffiAsyncSession`, so we
patch `curl_cffi.requests.AsyncSession` (the source) to intercept it.
"""
from app.services.scrapers import avito_imv
mock_session = MagicMock()
mock_warmup_resp = MagicMock()
mock_warmup_resp.status_code = 200
mock_geo_resp = MagicMock()
mock_geo_resp.status_code = 200
mock_geo_resp.json = MagicMock(return_value={"result": {"point": None}})
mock_session.get = AsyncMock(return_value=mock_warmup_resp)
mock_session.post = AsyncMock(return_value=mock_geo_resp)
mock_session.close = AsyncMock()
captured_kwargs: dict = {}
def _fake_session(*args, **kwargs):
captured_kwargs.update(kwargs)
return mock_session
# Patch the source so the local `from curl_cffi.requests import AsyncSession` picks it up
with patch("curl_cffi.requests.AsyncSession", _fake_session):
with patch("app.core.config.settings", _mock_settings(_PROXY_URL)):
try:
await avito_imv.evaluate_via_imv(
address="ул. Тургенева, 4",
rooms=2,
area_m2=50.0,
floor=3,
floor_at_home=9,
house_type="panel",
renovation_type="cosmetic",
has_balcony=False,
has_loggia=False,
)
except Exception:
pass # network/parse failures expected in unit test
assert captured_kwargs.get("proxies") == _EXPECTED_PROXIES