"""#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 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)