Два связанных фикса из диагностики 2026-06-27: ФИКС A (_rotate_proxy_ip): вместо одношотного GET с timeout=30s — retry-loop (proxy_rotate_attempts=3 попыток по proxy_rotate_attempt_timeout_s=8s каждая). Зависший changeip-сервис больше не блокирует весь sweep на 30s. Первый успешный attempt → True; все провалились → False (логируется как error). ФИКС B (run_avito_city_sweep): когда SERP-фаза уже сохранила лоты (lots_inserted + lots_updated > 0) и упала только detail/houses-фаза (AvitoBlockedError / RateLimitedError), помечаем run как done (не banned) при avito_serp_ok_not_banned=True (default). Метрика banned зарезервирована для случаев где SERP сам заблокирован (0 лотов). Новые настройки (ENV): PROXY_ROTATE_ATTEMPT_TIMEOUT_S=8.0 — timeout одной changeip-попытки PROXY_ROTATE_ATTEMPTS=3 — число попыток перед отказом AVITO_SERP_OK_NOT_BANNED=true — флаг нового поведения (default on)
245 lines
10 KiB
Python
245 lines
10 KiB
Python
"""Тесты 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
|