"""Тесты устойчивости скрейперов: avito changeip-retry (#1950) + cian anti-zombie (#1949). Fix A (#1950): _rotate_proxy_ip теперь делает до proxy_rotate_attempts попыток по proxy_rotate_attempt_timeout_s каждая. Fix B (#1950): run_avito_city_sweep при AvitoBlockedError/RateLimited с lots_inserted+lots_updated>0 вызывает mark_done (не mark_banned) когда avito_serp_ok_not_banned=True. Fix C (#1949): run_cian_full_load применяет monkey-patch _browser.fetch с asyncio.wait_for(timeout=cian_full_load_per_fetch_timeout_s). Fix D (#1949): run_cian_full_load создаёт фоновый heartbeat-task и отменяет его в finally при любом завершении. """ from __future__ import annotations import asyncio import os import time from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from app.services.scrapers.avito_exceptions import AvitoBlockedError from app.services.scrapers.base import ScrapedLot # ────────────────────────────────────────────────────────────────────────────── # Fix A: _rotate_proxy_ip retry loop (#1950) # ────────────────────────────────────────────────────────────────────────────── def _make_session_mock(get_side_effect: Any) -> AsyncMock: """Вспомогательный: AsyncMock-сессия с заданным поведением .get().""" mock_session = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) mock_session.get = AsyncMock(side_effect=get_side_effect) return mock_session @pytest.mark.asyncio async def test_changeip_retry_success_on_second_attempt() -> None: """#1950-A: первая попытка таймаут, вторая успех → возвращает True; ровно 2 GET-вызова.""" from app.services.scrape_pipeline import _rotate_proxy_ip get_call_count = 0 async def fake_get(url: str) -> MagicMock: nonlocal get_call_count get_call_count += 1 if get_call_count == 1: raise TimeoutError("connect timeout") resp = MagicMock() resp.json.return_value = {"new_ip": "1.2.3.4"} return resp mock_session = _make_session_mock(fake_get) with ( patch("app.services.scrape_pipeline.AsyncSession", return_value=mock_session), patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()), patch("app.services.scrape_pipeline.settings") as s, ): s.proxy_rotate_attempts = 3 s.proxy_rotate_attempt_timeout_s = 8.0 s.avito_proxy_rotate_settle_s = 0.0 s.avito_proxy_rotate_url = "http://changeip.example.com/rotate?token=X" s.avito_proxy_max_rotations = 3 result = await _rotate_proxy_ip( source="avito", reason="test-detail-block", rotations_done=0, ) assert result is True assert get_call_count == 2, f"expected 2 GET calls, got {get_call_count}" @pytest.mark.asyncio async def test_changeip_retry_all_fail_returns_false() -> None: """#1950-A: все 3 попытки падают с TimeoutError → False; ровно 3 GET-вызова.""" from app.services.scrape_pipeline import _rotate_proxy_ip get_call_count = 0 async def failing_get(url: str) -> None: nonlocal get_call_count get_call_count += 1 raise TimeoutError("connect timeout") mock_session = _make_session_mock(failing_get) with ( patch("app.services.scrape_pipeline.AsyncSession", return_value=mock_session), patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()), patch("app.services.scrape_pipeline.settings") as s, ): s.proxy_rotate_attempts = 3 s.proxy_rotate_attempt_timeout_s = 8.0 s.avito_proxy_rotate_settle_s = 0.0 s.avito_proxy_rotate_url = "http://changeip.example.com/rotate?token=X" s.avito_proxy_max_rotations = 3 result = await _rotate_proxy_ip( source="avito", reason="test-detail-block", rotations_done=0, ) assert result is False assert ( get_call_count == 3 ), f"expected exactly 3 attempts (proxy_rotate_attempts=3), got {get_call_count}" @pytest.mark.asyncio async def test_changeip_retry_bounded_by_setting() -> None: """#1950-A: proxy_rotate_attempts=2 → ровно 2 попытки, не больше.""" from app.services.scrape_pipeline import _rotate_proxy_ip call_count = 0 async def failing_get(url: str) -> None: nonlocal call_count call_count += 1 raise TimeoutError("timeout") mock_session = _make_session_mock(failing_get) with ( patch("app.services.scrape_pipeline.AsyncSession", return_value=mock_session), patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()), patch("app.services.scrape_pipeline.settings") as s, ): s.proxy_rotate_attempts = 2 s.proxy_rotate_attempt_timeout_s = 8.0 s.avito_proxy_rotate_settle_s = 0.0 s.cian_proxy_rotate_url = "http://changeip.example.com/rotate" s.avito_proxy_rotate_url = None s.cian_proxy_max_rotations = 3 result = await _rotate_proxy_ip( source="cian", reason="serp-block", rotations_done=1, ) assert result is False assert call_count == 2, f"proxy_rotate_attempts=2 should make exactly 2 calls, got {call_count}" # ────────────────────────────────────────────────────────────────────────────── # Fix B: честный статус run_avito_city_sweep (#1950) # ────────────────────────────────────────────────────────────────────────────── def _make_city_sweep_mocks() -> tuple[MagicMock, MagicMock, MagicMock]: """Возвращает (mock_db, mock_runs, mock_scraper) для тестов run_avito_city_sweep.""" mock_db = MagicMock() # priority_rows для detail-фазы: 3 строки с source_url class _DetailRow: def __getitem__(self, key: str) -> str: return "https://www.avito.ru/ekaterinburg/kvartiry/test-1" mock_db.execute.return_value.mappings.return_value.all.return_value = [ _DetailRow(), _DetailRow(), _DetailRow(), ] mock_runs = MagicMock() mock_runs.is_cancelled.return_value = False mock_runs.mark_done = MagicMock() mock_runs.mark_banned = MagicMock() mock_runs.mark_failed = MagicMock() mock_runs.update_heartbeat = MagicMock() mock_scraper = MagicMock() # AvitoScraper НЕ используется как async CM в _avito_anchor_phases (просто instantiate) mock_scraper.fetch_around = AsyncMock( return_value=[ ScrapedLot( source="avito", source_url=f"https://www.avito.ru/e/k/{i}", source_id=str(i), price_rub=5_000_000, address="ЕКБ test", listing_segment="vtorichka", ) for i in range(3) ] ) return mock_db, mock_runs, mock_scraper @pytest.mark.asyncio async def test_avito_city_sweep_serp_ok_marks_done_not_banned() -> None: """#1950-B: SERP собрал лоты (ins=3), detail заблокировало → mark_done не mark_banned. Условие: avito_serp_ok_not_banned=True (default), lots_inserted+lots_updated > 0. """ from app.services.scrape_pipeline import run_avito_city_sweep mock_db, mock_runs, mock_scraper = _make_city_sweep_mocks() # Shared AsyncSession mock (browser_mode=False) mock_session = AsyncMock() with ( patch("app.services.scrape_pipeline.AvitoScraper", return_value=mock_scraper), patch("app.services.scrape_pipeline.AsyncSession", return_value=mock_session), patch("app.services.scrape_pipeline.scrape_runs", mock_runs), patch("app.services.scrape_pipeline.save_listings", return_value=(3, 0)), patch( "app.services.scrape_pipeline.fetch_detail", AsyncMock(side_effect=AvitoBlockedError("HTTP 403 banned")), ), patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()), patch("app.services.scrape_pipeline.settings") as s, ): s.scraper_fetch_mode = "curl_cffi" s.scraper_proxy_url = None s.avito_proxy_max_rotations = 0 # no rotation budget s.avito_serp_ok_not_banned = True # ← наш флаг s.avito_proxy_rotate_settle_s = 0.0 s.proxy_rotate_attempts = 3 s.proxy_rotate_attempt_timeout_s = 8.0 await run_avito_city_sweep( mock_db, run_id=1, anchors=[(56.84, 60.61, "test-anchor")], detail_top_n=3, enrich_houses=False, enrich_imv=False, ) assert ( mock_runs.mark_done.called ), "mark_done должен быть вызван: SERP OK (ins=3), detail blocked" assert ( not mock_runs.mark_banned.called ), "mark_banned НЕ должен быть вызван когда SERP уже сохранил лоты" # enrichment_abort_note передан в counters call_args = mock_runs.mark_done.call_args # mark_done(db, run_id, counters_dict) passed_counters: dict = call_args[0][2] if call_args[0] else {} assert ( "enrichment_abort_note" in passed_counters ), "enrichment_abort_note должен присутствовать в counters переданных mark_done" @pytest.mark.asyncio async def test_avito_city_sweep_serp_zero_lots_marks_banned() -> None: """#1950-B: SERP вернул 0 лотов, SERP-блок → mark_banned (не mark_done). Условие: lots_inserted+lots_updated == 0. """ from app.services.scrape_pipeline import run_avito_city_sweep mock_db, mock_runs, _ = _make_city_sweep_mocks() # SERP сам поднимает AvitoBlockedError → anchor_lots не сохраняется mock_scraper_serp_block = MagicMock() mock_scraper_serp_block.fetch_around = AsyncMock(side_effect=AvitoBlockedError("SERP HTTP 403")) mock_session = AsyncMock() with ( patch("app.services.scrape_pipeline.AvitoScraper", return_value=mock_scraper_serp_block), patch("app.services.scrape_pipeline.AsyncSession", return_value=mock_session), patch("app.services.scrape_pipeline.scrape_runs", mock_runs), patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()), patch("app.services.scrape_pipeline.settings") as s, ): s.scraper_fetch_mode = "curl_cffi" s.scraper_proxy_url = None s.avito_proxy_max_rotations = 0 s.avito_serp_ok_not_banned = True # включён флаг, но лотов нет s.avito_proxy_rotate_settle_s = 0.0 s.proxy_rotate_attempts = 3 s.proxy_rotate_attempt_timeout_s = 8.0 await run_avito_city_sweep( mock_db, run_id=2, anchors=[(56.84, 60.61, "test-anchor")], detail_top_n=3, enrich_houses=False, enrich_imv=False, ) assert ( mock_runs.mark_banned.called ), "mark_banned должен быть вызван: SERP сам заблокирован, lots=0" assert not mock_runs.mark_done.called, "mark_done НЕ должен быть вызван при lots_inserted == 0" @pytest.mark.asyncio async def test_avito_city_sweep_flag_off_still_marks_banned() -> None: """#1950-B: avito_serp_ok_not_banned=False → mark_banned даже при lots>0 (backward-compat).""" from app.services.scrape_pipeline import run_avito_city_sweep mock_db, mock_runs, mock_scraper = _make_city_sweep_mocks() mock_session = AsyncMock() with ( patch("app.services.scrape_pipeline.AvitoScraper", return_value=mock_scraper), patch("app.services.scrape_pipeline.AsyncSession", return_value=mock_session), patch("app.services.scrape_pipeline.scrape_runs", mock_runs), patch("app.services.scrape_pipeline.save_listings", return_value=(3, 0)), patch( "app.services.scrape_pipeline.fetch_detail", AsyncMock(side_effect=AvitoBlockedError("HTTP 403")), ), patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()), patch("app.services.scrape_pipeline.settings") as s, ): s.scraper_fetch_mode = "curl_cffi" s.scraper_proxy_url = None s.avito_proxy_max_rotations = 0 s.avito_serp_ok_not_banned = False # ← флаг выключен s.avito_proxy_rotate_settle_s = 0.0 s.proxy_rotate_attempts = 3 s.proxy_rotate_attempt_timeout_s = 8.0 await run_avito_city_sweep( mock_db, run_id=3, anchors=[(56.84, 60.61, "test-anchor")], detail_top_n=3, enrich_houses=False, enrich_imv=False, ) assert ( mock_runs.mark_banned.called ), "avito_serp_ok_not_banned=False → mark_banned (backward-compat)" assert not mock_runs.mark_done.called # ────────────────────────────────────────────────────────────────────────────── # Fix C: cian per-fetch wait_for (#1949) # ────────────────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_cian_full_load_hanging_fetch_cancelled_by_timeout() -> None: """#1949-C: browser-fetch зависший навсегда отменяется per-fetch timeout. Monkey-patch заменяет _browser.fetch на _timed_fetch(wait_for timeout=0.05s). Зависший fetch отменяется через ~50ms → TimeoutError → run завершается. """ from app.services.scrape_pipeline import run_cian_full_load fetch_was_called = False async def hanging_fetch(url: str) -> str: nonlocal fetch_was_called fetch_was_called = True await asyncio.Event().wait() # висит вечно return "" mock_browser = MagicMock() mock_browser.fetch = hanging_fetch mock_scraper = MagicMock() mock_scraper.__aenter__ = AsyncMock(return_value=mock_scraper) mock_scraper.__aexit__ = AsyncMock(return_value=None) mock_scraper._browser = mock_browser mock_scraper.request_delay_sec = 0.0 # fetch_all_secondary симулирует _fetch_page_html: вызывает fetch и ловит Exception async def fake_fetch_all_secondary(**kwargs: object) -> None: try: # После monkey-patch _browser.fetch = _timed_fetch (с timeout=0.05s) await mock_scraper._browser.fetch("https://ekb.cian.ru/sale/flat/1/") except Exception: pass # TimeoutError → аналог _fetch_page_html → returns None mock_scraper.fetch_all_secondary = fake_fetch_all_secondary mock_db = MagicMock() mock_runs = MagicMock() mock_runs.is_cancelled.return_value = False mock_runs.mark_done = MagicMock() mock_runs.mark_failed = MagicMock() mock_runs.update_heartbeat = MagicMock() t0 = time.monotonic() with ( patch("app.services.scrapers.cian.CianScraper", return_value=mock_scraper), patch("app.services.scrape_pipeline.scrape_runs", mock_runs), patch("app.services.scrape_pipeline.settings") as s, ): s.cian_full_load_per_fetch_timeout_s = 0.05 # 50ms — быстрый timeout для теста s.cian_full_load_detail_top_n = 0 await run_cian_full_load(mock_db, run_id=1) elapsed = time.monotonic() - t0 assert fetch_was_called, "hanging_fetch должен быть вызван через monkey-patched _browser.fetch" assert ( elapsed < 3.0 ), f"run должен завершиться быстро (fetch timeout 50ms), занял {elapsed:.2f}s" assert mock_runs.mark_done.called, "run_cian_full_load должен завершиться mark_done" @pytest.mark.asyncio async def test_cian_full_load_per_fetch_timeout_disabled_when_zero() -> None: """#1949-C: cian_full_load_per_fetch_timeout_s=0 → monkey-patch НЕ применяется.""" from app.services.scrape_pipeline import run_cian_full_load original_fetch = AsyncMock(return_value="") mock_browser = MagicMock() mock_browser.fetch = original_fetch mock_scraper = MagicMock() mock_scraper.__aenter__ = AsyncMock(return_value=mock_scraper) mock_scraper.__aexit__ = AsyncMock(return_value=None) mock_scraper._browser = mock_browser mock_scraper.request_delay_sec = 0.0 mock_scraper.fetch_all_secondary = AsyncMock(return_value=None) mock_db = MagicMock() mock_runs = MagicMock() mock_runs.is_cancelled.return_value = False mock_runs.mark_done = MagicMock() mock_runs.mark_failed = MagicMock() mock_runs.update_heartbeat = MagicMock() with ( patch("app.services.scrapers.cian.CianScraper", return_value=mock_scraper), patch("app.services.scrape_pipeline.scrape_runs", mock_runs), patch("app.services.scrape_pipeline.settings") as s, ): s.cian_full_load_per_fetch_timeout_s = 0.0 # отключено s.cian_full_load_detail_top_n = 0 await run_cian_full_load(mock_db, run_id=2) # Когда timeout=0 (отключён), monkey-patch не применяется → fetch остаётся original assert ( mock_browser.fetch is original_fetch ), "cian_full_load_per_fetch_timeout_s=0 → fetch НЕ должен быть заменён monkey-patch" # ────────────────────────────────────────────────────────────────────────────── # Fix D: cian background heartbeat created and cancelled (#1949) # ────────────────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_cian_full_load_background_heartbeat_created_and_cancelled() -> None: """#1949-D: background heartbeat task создаётся и отменяется в finally.""" from app.services.scrape_pipeline import run_cian_full_load mock_scraper = MagicMock() mock_scraper.__aenter__ = AsyncMock(return_value=mock_scraper) mock_scraper.__aexit__ = AsyncMock(return_value=None) mock_scraper._browser = None # per-fetch timeout не применяется mock_scraper.fetch_all_secondary = AsyncMock(return_value=None) mock_db = MagicMock() mock_runs = MagicMock() mock_runs.is_cancelled.return_value = False mock_runs.mark_done = MagicMock() mock_runs.mark_failed = MagicMock() mock_runs.update_heartbeat = MagicMock() created_tasks: list[asyncio.Task[None]] = [] _real_create_task = asyncio.create_task def _tracking_create_task(coro: Any, *, name: str | None = None) -> asyncio.Task[None]: task = _real_create_task(coro, name=name) created_tasks.append(task) return task # type: ignore[return-value] with ( patch("app.services.scrapers.cian.CianScraper", return_value=mock_scraper), patch("app.services.scrape_pipeline.scrape_runs", mock_runs), patch( "app.services.scrape_pipeline.asyncio.create_task", side_effect=_tracking_create_task, ), patch("app.services.scrape_pipeline.settings") as s, ): s.cian_full_load_per_fetch_timeout_s = 0.0 s.cian_full_load_detail_top_n = 0 await run_cian_full_load(mock_db, run_id=3) assert ( len(created_tasks) >= 1 ), "asyncio.create_task должен быть вызван хотя бы раз (background heartbeat)" for task in created_tasks: assert ( task.cancelled() or task.done() ), f"Heartbeat task должен быть отменён в finally: {task}" @pytest.mark.asyncio async def test_cian_full_load_heartbeat_cancelled_on_exception() -> None: """#1949-D: heartbeat task отменяется в finally даже при исключении в run.""" from app.services.scrape_pipeline import run_cian_full_load mock_scraper = MagicMock() mock_scraper.__aenter__ = AsyncMock(return_value=mock_scraper) mock_scraper.__aexit__ = AsyncMock(return_value=None) mock_scraper._browser = None mock_scraper.fetch_all_secondary = AsyncMock(side_effect=RuntimeError("fatal-test-error")) mock_db = MagicMock() mock_runs = MagicMock() mock_runs.is_cancelled.return_value = False mock_runs.mark_done = MagicMock() mock_runs.mark_failed = MagicMock() mock_runs.update_heartbeat = MagicMock() created_tasks: list[asyncio.Task[None]] = [] _real_create_task = asyncio.create_task def _tracking_create_task(coro: Any, *, name: str | None = None) -> asyncio.Task[None]: task = _real_create_task(coro, name=name) created_tasks.append(task) return task # type: ignore[return-value] with ( patch("app.services.scrapers.cian.CianScraper", return_value=mock_scraper), patch("app.services.scrape_pipeline.scrape_runs", mock_runs), patch( "app.services.scrape_pipeline.asyncio.create_task", side_effect=_tracking_create_task, ), patch("app.services.scrape_pipeline.settings") as s, ): s.cian_full_load_per_fetch_timeout_s = 0.0 s.cian_full_load_detail_top_n = 0 with pytest.raises(RuntimeError, match="fatal-test-error"): await run_cian_full_load(mock_db, run_id=4) assert len(created_tasks) >= 1, "heartbeat task должен быть создан" for task in created_tasks: assert ( task.cancelled() or task.done() ), f"Heartbeat task должен быть отменён в finally даже при RuntimeError: {task}" assert mock_runs.mark_failed.called, "mark_failed должен быть вызван при RuntimeError"