"""Tests for scrape_pipeline.py proxy-pool wiring (#2202). Issue #2202: боевые скрейперы (scrape_pipeline.py Avito orchestrator) читали только статический settings.scraper_proxy_url — один mobile-IP на все прогоны, SPOF. Этот файл покрывает новые pool-aware helpers: - _pool_proxy_dict: acquire() health-filtered lease за флагом USE_PROXY_POOL_CURL, fallback на статический env при флаге off / пустом пуле / ошибке. - _rotate_pool_proxy: переключение на ДРУГОЙ здоровый прокси из пула при бане (mark_health(False)+release текущего, acquire() следующего, мутация session.proxies). - _rotate_avito_proxy: единая точка ротации — пул first, changeip fallback. Acceptance-тест (issue AC4): пул с 1 «забаненным» прокси — ротация выбирает СЛЕДУЮЩИЙ здоровый, не зависший на забаненном и не переиспользующий только что провалившийся lease раньше более свежего здорового кандидата. Offline: FakeSession эмулирует scrape_proxies-таблицу (тот же паттерн, что tests/services/test_proxy_pool.py), без live БД/сети. """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from datetime import UTC, datetime from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, patch import pytest from app.services import proxy_pool from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS from app.services.scrape_pipeline import ( _pool_proxy_dict, _release_pool_lease, _rotate_avito_proxy, _rotate_pool_proxy, ) # ── stateful fake session (mirrors tests/services/test_proxy_pool.py) ───────── class _FakeResult: def __init__(self, rows: list[dict[str, Any]]): self._rows = rows def mappings(self) -> _FakeResult: return self def fetchone(self) -> dict[str, Any] | None: return self._rows[0] if self._rows else None def all(self) -> list[dict[str, Any]]: return list(self._rows) class FakeSession: """Эмуляция Session поверх in-memory списка scrape_proxies-строк.""" def __init__(self, rows: list[dict[str, Any]]): self.rows = rows def _by_id(self, pid: int) -> dict[str, Any] | None: return next((r for r in self.rows if r["id"] == pid), None) def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: sql = str(stmt) p = params or {} if "FOR UPDATE SKIP LOCKED" in sql: # acquire SELECT provider = p["provider"] max_fails = p["max_fails"] cands = [ r for r in self.rows if r["enabled"] and r["consecutive_fails"] < max_fails and r["provider_affinity"] in (provider, "any") and r["leased_by"] is None ] cands.sort( key=lambda r: ( r["last_ok_at"] is None, r["last_ok_at"] or datetime.min.replace(tzinfo=UTC), r["id"], ) ) return _FakeResult(cands[:1]) if "SET leased_by = CAST(:run_id" in sql: # acquire lease UPDATE row = self._by_id(p["id"]) if row is not None: row["leased_by"] = p["run_id"] row["leased_at"] = datetime.now(UTC) return _FakeResult([]) if "SET leased_by = NULL" in sql: # release row = self._by_id(p["id"]) if row is not None: row["leased_by"] = None row["leased_at"] = None return _FakeResult([]) if "SET consecutive_fails = 0" in sql: # mark_health ok row = self._by_id(p["id"]) if row is not None: row["consecutive_fails"] = 0 row["exit_ip"] = p["exit_ip"] row["latency_ms"] = p["latency_ms"] row["last_ok_at"] = datetime.now(UTC) return _FakeResult([]) if "consecutive_fails = consecutive_fails + 1" in sql: # mark_health fail row = self._by_id(p["id"]) if row is not None: row["consecutive_fails"] += 1 if row["consecutive_fails"] >= p["disable_threshold"]: row["enabled"] = False return _FakeResult([]) raise AssertionError(f"unhandled SQL: {sql}") def commit(self) -> None: pass def rollback(self) -> None: pass def _proxy( pid: int, *, affinity: str = "avito", enabled: bool = True, fails: int = 0, leased_by: int | None = None, last_ok_at: datetime | None = None, ) -> dict[str, Any]: return { "id": pid, "url": f"http://u:p@h{pid}:8080", "kind": "http", "rotate_url": None, "provider_affinity": affinity, "enabled": enabled, "consecutive_fails": fails, "leased_by": leased_by, "leased_at": None, "last_ok_at": last_ok_at, "exit_ip": None, "latency_ms": None, } def _mock_settings(*, use_proxy_pool_curl: bool, scraper_proxy_url: str | None = None): return SimpleNamespace( use_proxy_pool_curl=use_proxy_pool_curl, scraper_proxy_url=scraper_proxy_url, ) # ── _pool_proxy_dict ──────────────────────────────────────────────────────── def test_pool_proxy_dict_falls_back_when_flag_off(): db = FakeSession([_proxy(1)]) with patch( "app.services.scrape_pipeline.settings", _mock_settings(use_proxy_pool_curl=False, scraper_proxy_url="http://env:3128"), ): proxies, lease = _pool_proxy_dict(db, "avito") # type: ignore[arg-type] assert proxies == {"http": "http://env:3128", "https": "http://env:3128"} assert lease is None def test_pool_proxy_dict_returns_lease_when_flag_on_and_healthy(): db = FakeSession([_proxy(1)]) with patch( "app.services.scrape_pipeline.settings", _mock_settings(use_proxy_pool_curl=True), ): proxies, lease = _pool_proxy_dict(db, "avito") # type: ignore[arg-type] assert lease is not None and lease.id == 1 assert proxies == {"http": "http://u:p@h1:8080", "https": "http://u:p@h1:8080"} assert db._by_id(1)["leased_by"] is not None # lease проставлен def test_pool_proxy_dict_falls_back_when_pool_empty(): db = FakeSession([]) # пустой пул with patch( "app.services.scrape_pipeline.settings", _mock_settings(use_proxy_pool_curl=True, scraper_proxy_url="http://env:3128"), ): proxies, lease = _pool_proxy_dict(db, "avito") # type: ignore[arg-type] assert lease is None assert proxies == {"http": "http://env:3128", "https": "http://env:3128"} # ── _rotate_pool_proxy — acceptance-тест AC4 ─────────────────────────────── @pytest.mark.asyncio async def test_rotate_pool_proxy_skips_banned_picks_next_healthy(): """#2202 AC4: пул с 1 забаненным прокси — ротация выбирает следующий здоровый. BAD (id=1) — уже забанен (fails>=MAX_CONSECUTIVE_FAILS), исключён acquire() на уровне SQL-фильтра. CURRENT (id=3) — прокси активного прогона, только что словивший бан (403/429) — вызывающий код зовёт rotate. GOOD (id=2) — свободный здоровый, единственный валидный кандидат после того как CURRENT будет помечен/освобождён. NB (#2202 MAJOR-fix): гарантия исключения CURRENT из acquire() держится на том, что release(current_lease) вызывается ПОСЛЕ acquire(), не до — пока current_lease leased_by-занят, SQL `leased_by IS NULL` исключает его вне зависимости от id/ last_ok_at сортировки. См. зеркальный тест ниже (test_rotate_pool_proxy_skips_banned_picks_next_healthy_current_id_lower) — та же гарантия при CURRENT id НИЖЕ GOOD id (иначе тай-брейк по id в ORDER BY "last_ok_at NULLS LAST, id" мог случайно фейворить эту фикстуру). """ db = FakeSession( [ _proxy(1, fails=MAX_CONSECUTIVE_FAILS), # BAD — уже забанен _proxy(2, fails=0), # GOOD — ожидаемый выбор _proxy(3, fails=0, leased_by=999), # CURRENT — в работе, банится сейчас ] ) current_lease = proxy_pool.ProxyLease( id=3, url="http://u:p@h3:8080", kind="http", rotate_url=None ) session = SimpleNamespace(proxies=None) new_lease = await _rotate_pool_proxy(db, "avito", session, current_lease) # type: ignore[arg-type] assert new_lease is not None assert new_lease.id == 2 # GOOD, не BAD (excluded) и не CURRENT (только что провален) assert session.proxies == {"http": "http://u:p@h2:8080", "https": "http://u:p@h2:8080"} # CURRENT (id=3) помечен нездоровым и освобождён assert db._by_id(3)["consecutive_fails"] == 1 assert db._by_id(3)["leased_by"] is None # BAD (id=1) не тронут этой операцией assert db._by_id(1)["consecutive_fails"] == MAX_CONSECUTIVE_FAILS # GOOD (id=2) теперь зализан под новую ротацию assert db._by_id(2)["leased_by"] is not None @pytest.mark.asyncio async def test_rotate_pool_proxy_skips_banned_picks_next_healthy_current_id_lower(): """#2202 AC4 regression guard (MAJOR-fix): гарантия исключения current_lease НЕ должна зависеть от того, чей id меньше в фикстуре. Зеркало test_rotate_pool_proxy_skips_banned_picks_next_healthy с CURRENT/GOOD id в обратном порядке: CURRENT=1 < GOOD=2 (оригинальный тест имел CURRENT=3 > GOOD=2, поэтому "тай-брейк по id" в ORDER BY last_ok_at NULLS LAST, id случайно совпадал с правильным ответом и не доказывал реальную гарантию — см. review-репро на #2202). До фикса (release(current_lease) ДО acquire()): CURRENT освобождается, попадает в leased_by IS NULL кандидаты с fails=1 (< MAX_CONSECUTIVE_FAILS=3, т.е. "здоров"), и при равном last_ok_at (оба NULL) выигрывает тай-брейк по id (1 < 2) — acquire() возвращает ТОТ ЖЕ прокси, который только что забанили. Этот тест ловит именно этот регресс: если release() снова передвинуть перед acquire(), new_lease.id будет 1 (CURRENT), а не 2 (GOOD), и assert упадёт. """ db = FakeSession( [ _proxy(1, fails=0, leased_by=999), # CURRENT — id НИЖЕ GOOD, банится сейчас _proxy(2, fails=0), # GOOD — единственный здоровый свободный кандидат ] ) current_lease = proxy_pool.ProxyLease( id=1, url="http://u:p@h1:8080", kind="http", rotate_url=None ) session = SimpleNamespace(proxies=None) new_lease = await _rotate_pool_proxy(db, "avito", session, current_lease) # type: ignore[arg-type] assert new_lease is not None assert new_lease.id == 2 # GOOD — НЕ CURRENT, несмотря на id=1 < id=2 assert session.proxies == {"http": "http://u:p@h2:8080", "https": "http://u:p@h2:8080"} # CURRENT (id=1) помечен нездоровым и освобождён (после успешной ротации) assert db._by_id(1)["consecutive_fails"] == 1 assert db._by_id(1)["leased_by"] is None # GOOD (id=2) теперь зализан под новую ротацию assert db._by_id(2)["leased_by"] is not None @pytest.mark.asyncio async def test_rotate_pool_proxy_exhausted_returns_none(): """Все кандидаты нездоровы после ротации → None (caller падает на changeip). BAD (id=1) уже забанен. CURRENT (id=3) на MAX_CONSECUTIVE_FAILS-1 fails — ЭТА ротация (mark_health ok=False) толкает его ровно до порога → тоже исключается из acquire(). Ни одного здорового кандидата не остаётся. """ db = FakeSession( [ _proxy(1, fails=MAX_CONSECUTIVE_FAILS), # BAD — уже забанен _proxy(3, fails=MAX_CONSECUTIVE_FAILS - 1, leased_by=999), # CURRENT — на грани ] ) current_lease = proxy_pool.ProxyLease( id=3, url="http://u:p@h3:8080", kind="http", rotate_url=None ) session = SimpleNamespace(proxies=None) new_lease = await _rotate_pool_proxy(db, "avito", session, current_lease) # type: ignore[arg-type] assert new_lease is None assert session.proxies is None # не тронут — caller остаётся на старом/changeip assert db._by_id(3)["leased_by"] is None # текущий всё равно освобождён # ── _rotate_avito_proxy — pool-first, changeip fallback ──────────────────── @pytest.mark.asyncio async def test_rotate_avito_proxy_uses_pool_when_flag_on(): db = FakeSession( [ _proxy(1, fails=0), # свободный здоровый — цель ротации _proxy(3, fails=0, leased_by=999), # CURRENT ] ) current_lease = proxy_pool.ProxyLease( id=3, url="http://u:p@h3:8080", kind="http", rotate_url=None ) session = SimpleNamespace(proxies=None) with patch("app.services.scrape_pipeline.settings", _mock_settings(use_proxy_pool_curl=True)): with patch("app.services.scrape_pipeline._rotate_proxy_ip", AsyncMock()) as changeip_mock: rotated, new_lease = await _rotate_avito_proxy( db, # type: ignore[arg-type] session, # type: ignore[arg-type] current_lease, reason="test", rotations_done=0, ) assert rotated is True assert new_lease is not None and new_lease.id == 1 changeip_mock.assert_not_awaited() # пул сработал — changeip не нужен @pytest.mark.asyncio async def test_rotate_avito_proxy_falls_back_to_changeip_when_flag_off(): session = SimpleNamespace(proxies=None) with patch("app.services.scrape_pipeline.settings", _mock_settings(use_proxy_pool_curl=False)): with patch( "app.services.scrape_pipeline._rotate_proxy_ip", AsyncMock(return_value=True) ) as changeip_mock: rotated, new_lease = await _rotate_avito_proxy( None, # type: ignore[arg-type] session, # type: ignore[arg-type] None, reason="test", rotations_done=0, ) assert rotated is True assert new_lease is None changeip_mock.assert_awaited_once() @pytest.mark.asyncio async def test_rotate_avito_proxy_falls_back_to_changeip_when_pool_exhausted(): """Пул on, но acquire() пуст (например все забанены) — падаем на changeip, не молчим.""" db = FakeSession([_proxy(1, fails=MAX_CONSECUTIVE_FAILS)]) # единственный — забанен session = SimpleNamespace(proxies=None) with patch("app.services.scrape_pipeline.settings", _mock_settings(use_proxy_pool_curl=True)): with patch( "app.services.scrape_pipeline._rotate_proxy_ip", AsyncMock(return_value=True) ) as changeip_mock: rotated, new_lease = await _rotate_avito_proxy( db, # type: ignore[arg-type] session, # type: ignore[arg-type] None, reason="test", rotations_done=0, ) assert rotated is True assert new_lease is None changeip_mock.assert_awaited_once() # pool exhausted → fallback path taken # ── _release_pool_lease ────────────────────────────────────────────────────── def test_release_pool_lease_noop_when_none(): _release_pool_lease(None, None, ok=True) # type: ignore[arg-type] # must not raise def test_release_pool_lease_marks_health_and_releases(): db = FakeSession([_proxy(1, leased_by=100)]) lease = proxy_pool.ProxyLease(id=1, url="http://u:p@h1:8080", kind="http", rotate_url=None) _release_pool_lease(db, lease, ok=True) # type: ignore[arg-type] assert db._by_id(1)["leased_by"] is None assert db._by_id(1)["consecutive_fails"] == 0