gendesign/tradein-mvp/backend/tests/test_ban_rotation_1790.py
lekss361 308eeac214
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
fix(tradein/scrapers): wire proxy_pool into Avito legacy pipeline, rotate on ban (#2202) (#2328)
2026-07-03 21:48:15 +00:00

220 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#1790 — ban-triggered IP rotation в enrichment-фазах scrape_pipeline.
Тест 1: AvitoBlockedError при доступных ротациях → вызывается _rotate_proxy_ip
(changeip) + consecutive счётчик сбрасывается → enrichment продолжается.
Тест 2: Ротаций больше нет (бюджет исчерпан) → abort enrichment (bounded N ротаций).
Тест 3: Graceful per-item (#1820) сохранён — один блок без порога ротации НЕ вызывает.
Без сети, без БД.
"""
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 (
_AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT,
PipelineResult,
run_avito_pipeline,
)
from app.services.scrapers.avito_exceptions import AvitoBlockedError
def _make_lot(
item_id: str,
house_url: str | None = None,
) -> object:
from app.services.scrapers.base import ScrapedLot
return ScrapedLot(
source="avito",
source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/test_{item_id}",
source_id=item_id,
price_rub=5_000_000,
address="Test address",
house_source="avito" if house_url else None,
house_ext_id=house_url.rstrip("/").split("/")[-1] if house_url else None,
house_url=house_url,
listing_segment="vtorichka",
)
def _block_scraper(lots: list) -> MagicMock: # type: ignore[type-arg]
scraper = MagicMock()
scraper.__aenter__ = AsyncMock(return_value=scraper)
scraper.__aexit__ = AsyncMock(return_value=None)
scraper.fetch_around = AsyncMock(return_value=lots)
scraper._cffi = None
return scraper
# ---------------------------------------------------------------------------
# Тест 1: ротация срабатывает при consecutive blocks == порог
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_rotation_called_on_consecutive_blocks() -> None:
"""При N подряд-блоках (порог) вызывается _rotate_proxy_ip — IP меняется.
После успешной ротации consecutive счётчик сбрасывается и enrichment продолжается:
следующий (N+1)-й дом фетчится успешно.
"""
n = _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
lots = [
_make_lot(str(i), f"https://www.avito.ru/catalog/houses/ekb/h{i}/{100 + i}")
for i in range(n + 2) # n блокируются, потом ротация, потом успех
]
scraper = _block_scraper(lots)
# Первые n обращений — блок, (n+1)-й — успех
fetch_house = AsyncMock(side_effect=[AvitoBlockedError("banned")] * n + [MagicMock()])
rotate_mock = AsyncMock(return_value=True) # ротация успешна
with (
patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
patch("app.services.scrape_pipeline.save_listings", return_value=(len(lots), 0)),
patch("app.services.scrape_pipeline.fetch_house_catalog", fetch_house),
patch(
"app.services.scrape_pipeline.save_house_catalog_enrichment",
return_value={"house_id": 99},
),
patch("app.services.scrape_pipeline._rotate_proxy_ip", rotate_mock),
patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()),
patch(
"curl_cffi.requests.AsyncSession",
return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
),
):
result = await run_avito_pipeline(
MagicMock(), 56.8, 60.6, 1000, enrich_houses=True, enrich_detail_top_n=0
)
assert isinstance(result, PipelineResult)
# _rotate_proxy_ip вызвана хотя бы один раз
assert rotate_mock.await_count >= 1
# Enrichment продолжился после ротации — (n+1)-й дом обогащён
assert result.counters.houses_enriched >= 1
# Listings сохранены
assert result.counters.lots_inserted == len(lots)
# ---------------------------------------------------------------------------
# Тест 2: бюджет ротаций исчерпан → abort
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_rotation_bounded_abort_when_budget_exhausted() -> None:
"""После max_rotations попыток ротации enrichment abort'ится (bounded).
Используем avito_proxy_max_rotations=1 → только одна попытка ротации.
После неё блоки продолжаются → abort.
"""
n = _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
# Достаточно домов чтобы вызвать и ротацию и повторные блоки
lots = [
_make_lot(str(i), f"https://www.avito.ru/catalog/houses/ekb/h{i}/{200 + i}")
for i in range(n * 3)
]
scraper = _block_scraper(lots)
# Все дома блокируются (не будет успешного фетча)
fetch_house = AsyncMock(side_effect=AvitoBlockedError("banned"))
# Ротация успешна, но дом после неё тоже блокируется
rotate_mock = AsyncMock(return_value=True)
with (
patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
patch("app.services.scrape_pipeline.save_listings", return_value=(len(lots), 0)),
patch("app.services.scrape_pipeline.fetch_house_catalog", fetch_house),
patch("app.services.scrape_pipeline.save_house_catalog_enrichment", return_value={}),
patch("app.services.scrape_pipeline._rotate_proxy_ip", rotate_mock),
patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()),
patch("app.services.scrape_pipeline.settings") as mock_settings,
patch(
"curl_cffi.requests.AsyncSession",
return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
),
):
# Лимит 1 ротация: после неё бюджет исчерпан → abort на следующей серии блоков
mock_settings.avito_proxy_max_rotations = 1
mock_settings.avito_proxy_rotate_url = "http://rotate.test/"
mock_settings.avito_proxy_rotate_settle_s = 0.0
mock_settings.scraper_fetch_mode = "curl_cffi"
mock_settings.scraper_proxy_url = None
mock_settings.scraper_skip_seen_today = False
# #2202: bare MagicMock settings auto-truthy on any attribute access — pin
# explicitly off so _rotate_avito_proxy takes the changeip path this test
# asserts on (pool path is covered separately in test_scrape_pipeline_proxy_pool.py).
mock_settings.use_proxy_pool_curl = False
result = await run_avito_pipeline(
MagicMock(), 56.8, 60.6, 1000, enrich_houses=True, enrich_detail_top_n=0
)
assert isinstance(result, PipelineResult)
# Ровно 1 ротация (бюджет = 1)
assert rotate_mock.await_count == 1
# Enrichment abort'нут (есть AVITO_BLOCKED в ошибках)
assert any("AVITO_BLOCKED" in e for e in result.counters.errors)
# Listings всё равно сохранены
assert result.counters.lots_inserted == len(lots)
# ---------------------------------------------------------------------------
# Тест 3: один блок (ниже порога) — ротация НЕ вызывается (#1820 graceful)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_single_block_below_threshold_no_rotation() -> None:
"""Один блок (< порога) — _rotate_proxy_ip НЕ вызывается.
Graceful per-item #1820: единственный блок пишется в errors, enrichment
продолжается, ротация не тратится.
"""
n = _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
# Только 2 дома: первый блокируется, второй успешен
lots = [
_make_lot("1", "https://www.avito.ru/catalog/houses/ekb/a/100"),
_make_lot("2", "https://www.avito.ru/catalog/houses/ekb/b/200"),
]
assert n >= 2, "Порог должен быть >= 2 чтобы тест был корректен"
scraper = _block_scraper(lots)
fetch_house = AsyncMock(side_effect=[AvitoBlockedError("HTTP 403"), MagicMock()])
rotate_mock = AsyncMock(return_value=True)
with (
patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
patch("app.services.scrape_pipeline.save_listings", return_value=(2, 0)),
patch("app.services.scrape_pipeline.fetch_house_catalog", fetch_house),
patch(
"app.services.scrape_pipeline.save_house_catalog_enrichment",
return_value={"house_id": 55},
),
patch("app.services.scrape_pipeline._rotate_proxy_ip", rotate_mock),
patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()),
patch(
"curl_cffi.requests.AsyncSession",
return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
),
):
result = await run_avito_pipeline(
MagicMock(), 56.8, 60.6, 1000, enrich_houses=True, enrich_detail_top_n=0
)
# Ротация НЕ вызвана — один блок ниже порога
assert rotate_mock.await_count == 0
# Первый дом упал, второй обогащён
assert result.counters.houses_failed == 1
assert result.counters.houses_enriched == 1
assert any("BLOCKED_HOUSE" in e for e in result.counters.errors)