All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m34s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 59s
408 lines
17 KiB
Python
408 lines
17 KiB
Python
"""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
|