"""Тесты Fix 2: provider-aware _rotate_proxy_ip (#1848). Тест 1: source='cian' → cian_proxy_rotate_url используется. Тест 2: source='yandex' → yandex_proxy_rotate_url используется. Тест 3: source='avito' (default) → avito_proxy_rotate_url (backward compat). Тест 4: cian без cian url → fallback на avito_proxy_rotate_url. Тест 5: rotate_url=None → returns False (no HTTP call). Тест 6: network error → returns False (no crash). Тест 7: yandex fallback на avito. """ from __future__ import annotations import os from unittest.mock import AsyncMock, MagicMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from app.services.scrape_pipeline import _rotate_proxy_ip # ── Helpers ────────────────────────────────────────────────────────────────── def _async_session_ctx(resp_json: dict | None = None): """Возвращает (async_cm_class, session_mock) для curl_cffi AsyncSession.""" session = AsyncMock() if resp_json is not None: mock_resp = MagicMock() mock_resp.json.return_value = resp_json session.get = AsyncMock(return_value=mock_resp) else: session.get = AsyncMock(side_effect=RuntimeError("network error")) # curl_cffi AsyncSession используется как `async with AsyncSession(...) as rot:` # Нам нужен callable класс-замена, вызов которого возвращает async context manager. class _MockAsyncSession: def __init__(self, timeout=30): pass async def __aenter__(self): return session async def __aexit__(self, *_): pass return _MockAsyncSession, session # ── Тест 1: cian_proxy_rotate_url ──────────────────────────────────────────── @pytest.mark.asyncio async def test_rotate_proxy_ip_cian_uses_cian_url() -> None: """source='cian' → cian_proxy_rotate_url (не avito_proxy_rotate_url).""" mock_session_cls, session = _async_session_ctx(resp_json={"new_ip": "1.2.3.4"}) with ( patch("app.services.scrape_pipeline.settings") as s, patch("app.services.scrape_pipeline.AsyncSession", mock_session_cls), patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()), ): s.cian_proxy_rotate_url = "http://cian-rotate.test/" s.avito_proxy_rotate_url = "http://avito-rotate.test/" s.yandex_proxy_rotate_url = None s.cian_proxy_max_rotations = 4 s.avito_proxy_max_rotations = 4 s.yandex_proxy_max_rotations = 4 s.avito_proxy_rotate_settle_s = 0.0 result = await _rotate_proxy_ip(reason="ban", rotations_done=0, source="cian") assert result is True called_url: str = session.get.call_args[0][0] assert "cian-rotate.test" in called_url assert "avito-rotate.test" not in called_url # ── Тест 2: yandex_proxy_rotate_url ───────────────────────────────────────── @pytest.mark.asyncio async def test_rotate_proxy_ip_yandex_uses_yandex_url() -> None: """source='yandex' → yandex_proxy_rotate_url.""" mock_session_cls, session = _async_session_ctx(resp_json={"new_ip": "5.6.7.8"}) with ( patch("app.services.scrape_pipeline.settings") as s, patch("app.services.scrape_pipeline.AsyncSession", mock_session_cls), patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()), ): s.yandex_proxy_rotate_url = "http://yandex-rotate.test/" s.avito_proxy_rotate_url = "http://avito-rotate.test/" s.cian_proxy_rotate_url = None s.yandex_proxy_max_rotations = 4 s.avito_proxy_max_rotations = 4 s.cian_proxy_max_rotations = 4 s.avito_proxy_rotate_settle_s = 0.0 result = await _rotate_proxy_ip(reason="timeout", rotations_done=0, source="yandex") assert result is True called_url: str = session.get.call_args[0][0] assert "yandex-rotate.test" in called_url assert "avito-rotate.test" not in called_url # ── Тест 3: avito backward compat ──────────────────────────────────────────── @pytest.mark.asyncio async def test_rotate_proxy_ip_avito_backward_compat() -> None: """source не передан (дефолт 'avito') → avito_proxy_rotate_url.""" mock_session_cls, session = _async_session_ctx(resp_json={"new_ip": "9.9.9.9"}) with ( patch("app.services.scrape_pipeline.settings") as s, patch("app.services.scrape_pipeline.AsyncSession", mock_session_cls), patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()), ): s.avito_proxy_rotate_url = "http://avito-only.test/" s.cian_proxy_rotate_url = None s.yandex_proxy_rotate_url = None s.avito_proxy_max_rotations = 4 s.cian_proxy_max_rotations = 4 s.yandex_proxy_max_rotations = 4 s.avito_proxy_rotate_settle_s = 0.0 # Вызов без source= — должен работать как раньше result = await _rotate_proxy_ip(reason="block", rotations_done=0) assert result is True called_url: str = session.get.call_args[0][0] assert "avito-only.test" in called_url # ── Тест 4: cian fallback на avito когда нет cian url ───────────────────── @pytest.mark.asyncio async def test_rotate_proxy_ip_cian_fallback_to_avito() -> None: """cian_proxy_rotate_url=None → fallback на avito_proxy_rotate_url.""" mock_session_cls, session = _async_session_ctx(resp_json={}) with ( patch("app.services.scrape_pipeline.settings") as s, patch("app.services.scrape_pipeline.AsyncSession", mock_session_cls), patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()), ): s.cian_proxy_rotate_url = None s.avito_proxy_rotate_url = "http://avito-fallback.test/" s.yandex_proxy_rotate_url = None s.cian_proxy_max_rotations = 4 s.avito_proxy_max_rotations = 4 s.yandex_proxy_max_rotations = 4 s.avito_proxy_rotate_settle_s = 0.0 result = await _rotate_proxy_ip(reason="ban", rotations_done=0, source="cian") assert result is True called_url: str = session.get.call_args[0][0] assert "avito-fallback.test" in called_url # ── Тест 5: rotate_url=None → returns False ────────────────────────────────── @pytest.mark.asyncio async def test_rotate_proxy_ip_returns_false_when_no_url() -> None: """Никакой URL не задан → False, HTTP-запрос не делается.""" mock_session_cls, session = _async_session_ctx(resp_json={}) with ( patch("app.services.scrape_pipeline.settings") as s, patch("app.services.scrape_pipeline.AsyncSession", mock_session_cls), ): s.cian_proxy_rotate_url = None s.avito_proxy_rotate_url = None s.yandex_proxy_rotate_url = None s.cian_proxy_max_rotations = 4 s.avito_proxy_max_rotations = 4 s.yandex_proxy_max_rotations = 4 result = await _rotate_proxy_ip(reason="ban", rotations_done=0, source="cian") assert result is False # HTTP-запрос не делался session.get.assert_not_called() # ── Тест 6: network error → returns False ──────────────────────────────────── @pytest.mark.asyncio async def test_rotate_proxy_ip_returns_false_on_network_error() -> None: """Ошибка сети → returns False (не крашит caller).""" mock_session_cls, _session = _async_session_ctx(resp_json=None) # raises RuntimeError with ( patch("app.services.scrape_pipeline.settings") as s, patch("app.services.scrape_pipeline.AsyncSession", mock_session_cls), patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()), ): s.cian_proxy_rotate_url = "http://cian-rotate.test/" s.avito_proxy_rotate_url = None s.yandex_proxy_rotate_url = None s.cian_proxy_max_rotations = 4 s.avito_proxy_max_rotations = 4 s.yandex_proxy_max_rotations = 4 s.avito_proxy_rotate_settle_s = 0.0 s.proxy_rotate_attempts = 1 # одна попытка, без retry-sleep (#1950) s.proxy_rotate_attempt_timeout_s = 8.0 result = await _rotate_proxy_ip(reason="ban", rotations_done=0, source="cian") assert result is False # ── Тест 7: yandex fallback на avito ───────────────────────────────────────── @pytest.mark.asyncio async def test_rotate_proxy_ip_yandex_fallback_to_avito() -> None: """yandex_proxy_rotate_url=None → fallback на avito_proxy_rotate_url.""" mock_session_cls, session = _async_session_ctx(resp_json={"new_ip": "11.22.33.44"}) with ( patch("app.services.scrape_pipeline.settings") as s, patch("app.services.scrape_pipeline.AsyncSession", mock_session_cls), patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()), ): s.yandex_proxy_rotate_url = None s.avito_proxy_rotate_url = "http://shared-proxy.test/" s.cian_proxy_rotate_url = None s.yandex_proxy_max_rotations = 4 s.avito_proxy_max_rotations = 4 s.cian_proxy_max_rotations = 4 s.avito_proxy_rotate_settle_s = 0.0 result = await _rotate_proxy_ip(reason="503", rotations_done=0, source="yandex") assert result is True called_url: str = session.get.call_args[0][0] assert "shared-proxy.test" in called_url