"""Offline unit tests for run_yandex_city_sweep (SERP+address-enrich, #861). Pure & fast: NO live network, NO DB. We monkeypatch: - YandexRealtyScraper.__init__ / __aenter__ / __aexit__ — avoid get_scraper_delay DB read + real curl_cffi session creation. - YandexRealtyScraper.fetch_around_multi_room — return a fixed list of fake ScrapedLot. - scrape_pipeline.save_listings — counter/no-op (no real listings table). - scrape_runs.* (is_cancelled / update_heartbeat / mark_*) — in-memory fakes. - curl_cffi.requests.AsyncSession — fake for address-enrich HTTP calls. Asserts orchestration semantics: - SERP → save (all anchors, counters aggregated) - address-enrich фаза вызывается если enrich_address=True и есть source_ids - enrich_address=False → адрес-фаза пропускается - cancel → останавливает sweep - N consecutive SERP failures → mark_banned + abort - per-item address-enrich error не валит sweep - mark_done вызывается при нормальном завершении """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from app.services import scrape_pipeline from app.services.scrapers.base import ScrapedLot from app.services.scrapers.yandex_realty import YandexRealtyScraper # 3 anchors keep tests fast and let us probe cancel/fail semantics. TEST_ANCHORS: list[tuple[float, float, str]] = [ (56.8400, 60.6050, "A1"), (56.7950, 60.5300, "A2"), (56.8970, 60.6100, "A3"), ] def _fake_lot(offer_id: str) -> ScrapedLot: """Minimal valid ScrapedLot (source/source_url required, price_rub > 0).""" return ScrapedLot( source="yandex", source_url=f"https://realty.yandex.ru/offer/{offer_id}/", source_id=offer_id, address="Екатеринбург, улица Тестовая", price_rub=5_000_000, area_m2=50.0, rooms=2, ) class _FakeRuns: """In-memory stand-in for app.services.scrape_runs (no DB).""" def __init__(self, *, cancel_at_anchor: int | None = None) -> None: # cancel_at_anchor: 1-based anchor index at which is_cancelled flips True. self.cancel_at_anchor = cancel_at_anchor self.is_cancelled_calls = 0 self.heartbeats: list[dict[str, int]] = [] self.done: dict[str, int] | None = None self.failed: tuple[str, dict[str, int]] | None = None self.banned: tuple[str, dict[str, int]] | None = None def is_cancelled(self, _db: Any, _run_id: int) -> bool: self.is_cancelled_calls += 1 if self.cancel_at_anchor is None: return False return self.is_cancelled_calls >= self.cancel_at_anchor def update_heartbeat(self, _db: Any, _run_id: int, counters: dict[str, int]) -> None: self.heartbeats.append(dict(counters)) def mark_done(self, _db: Any, _run_id: int, counters: dict[str, int]) -> None: self.done = dict(counters) def mark_failed(self, _db: Any, _run_id: int, error: str, counters: dict[str, int]) -> None: self.failed = (error, dict(counters)) def mark_banned(self, _db: Any, _run_id: int, error: str, counters: dict[str, int]) -> None: self.banned = (error, dict(counters)) @pytest.fixture(autouse=True) def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: """Kill inter-anchor asyncio.sleep so tests are instant.""" async def _instant(_secs: float) -> None: return None monkeypatch.setattr(scrape_pipeline.asyncio, "sleep", _instant) @pytest.fixture(autouse=True) def _stub_scraper_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: """Neutralize __init__/__aenter__/__aexit__ — no get_scraper_delay DB, no curl_cffi.""" def _init(self: YandexRealtyScraper, city: str = "ekaterinburg") -> None: self.city = city self.request_delay_sec = 0.0 self._cffi_session = None self._cookies = {} async def _aenter(self: YandexRealtyScraper) -> YandexRealtyScraper: return self async def _aexit(self: YandexRealtyScraper, *_args: Any) -> None: return None monkeypatch.setattr(YandexRealtyScraper, "__init__", _init) monkeypatch.setattr(YandexRealtyScraper, "__aenter__", _aenter) monkeypatch.setattr(YandexRealtyScraper, "__aexit__", _aexit) def _install_runs(monkeypatch: pytest.MonkeyPatch, fake: _FakeRuns) -> None: monkeypatch.setattr(scrape_pipeline.scrape_runs, "is_cancelled", fake.is_cancelled) monkeypatch.setattr(scrape_pipeline.scrape_runs, "update_heartbeat", fake.update_heartbeat) monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_done", fake.mark_done) monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_failed", fake.mark_failed) monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_banned", fake.mark_banned) def _make_db_mock(source_ids: list[str]) -> MagicMock: """DB mock для address-enrich фазы: возвращает строки без номера дома.""" mock_db = MagicMock() rows = [ { "id": i + 1, "source_id": sid, "address": "Екатеринбург, улица Тестовая", "source_url": f"https://realty.yandex.ru/offer/{sid}/", } for i, sid in enumerate(source_ids) ] mock_db.execute.return_value.mappings.return_value.all.return_value = rows return mock_db def _fake_save(_db: Any, lots: list, *, run_id: int | None = None) -> tuple[int, int]: return len(lots), 0 # ── YandexCitySweepCounters ───────────────────────────────────────────────── def test_counters_defaults() -> None: from app.services.scrape_pipeline import YandexCitySweepCounters c = YandexCitySweepCounters() assert c.anchors_total == 0 assert c.lots_fetched == 0 assert c.address_attempted == 0 assert c.address_enriched == 0 assert c.address_failed == 0 assert c.errors_count == 0 def test_counters_to_dict_all_keys() -> None: from dataclasses import fields from app.services.scrape_pipeline import YandexCitySweepCounters c = YandexCitySweepCounters() d = c.to_dict() expected = {f.name for f in fields(c)} assert set(d.keys()) == expected # ── Happy path: all anchors iterated, counters aggregated ─────────────────── async def test_sweep_iterates_all_anchors_and_aggregates( monkeypatch: pytest.MonkeyPatch, ) -> None: """fetch_around_multi_room вызывается для каждого anchor, counters агрегируются.""" serp_calls: list[str] = [] save_calls: list[int] = [] async def fake_fetch_multi( self: YandexRealtyScraper, lat: float, lon: float, radius_m: int = 1000, max_pages: int = 2, **_: Any, ) -> list[ScrapedLot]: serp_calls.append(f"{lat:.4f}") return [_fake_lot(f"{lat}-{i}") for i in range(3)] def fake_save( _db: Any, lots: list[ScrapedLot], *, run_id: int | None = None ) -> tuple[int, int]: save_calls.append(len(lots)) return len(lots), 0 monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save) fake = _FakeRuns() _install_runs(monkeypatch, fake) counters = await scrape_pipeline.run_yandex_city_sweep( db=MagicMock(), run_id=1, anchors=TEST_ANCHORS, pages_per_anchor=2, request_delay_sec=0.0, enrich_address=False, ) assert len(serp_calls) == len(TEST_ANCHORS) assert len(save_calls) == len(TEST_ANCHORS) assert counters.anchors_total == len(TEST_ANCHORS) assert counters.anchors_done == len(TEST_ANCHORS) assert counters.lots_fetched == len(TEST_ANCHORS) * 3 assert counters.lots_inserted == len(TEST_ANCHORS) * 3 assert counters.lots_updated == 0 assert counters.errors_count == 0 assert counters.address_attempted == 0 # enrich_address=False assert fake.done is not None assert fake.done["lots_fetched"] == len(TEST_ANCHORS) * 3 assert fake.failed is None assert fake.banned is None # ── enrich_address=False пропускает address-фазу ──────────────────────────── async def test_enrich_address_false_skips_address_phase( monkeypatch: pytest.MonkeyPatch, ) -> None: """enrich_address=False → address-enrich фаза не выполняется.""" address_http_calls: list[str] = [] async def fake_fetch_multi( self: YandexRealtyScraper, lat: float, lon: float, radius_m: int = 1000, max_pages: int = 2, **_: Any, ) -> list[ScrapedLot]: return [_fake_lot(f"{lat}-0")] async def fake_cffi_get(url: str, **_: Any) -> Any: address_http_calls.append(url) return MagicMock(status_code=200, text="test") monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) fake = _FakeRuns() _install_runs(monkeypatch, fake) counters = await scrape_pipeline.run_yandex_city_sweep( db=MagicMock(), run_id=2, anchors=TEST_ANCHORS[:1], enrich_address=False, request_delay_sec=0.0, ) assert len(address_http_calls) == 0 assert counters.address_attempted == 0 assert counters.address_enriched == 0 assert fake.done is not None # ── address-enrich фаза: счётчики заполняются ─────────────────────────────── async def test_address_enrich_fills_counters( monkeypatch: pytest.MonkeyPatch, ) -> None: """address-enrich вызывается для листингов без номера дома; counters заполняются.""" # Fake SERP возвращает 2 листинга с source_id lots = [_fake_lot("id1"), _fake_lot("id2")] async def fake_fetch_multi( self: YandexRealtyScraper, lat: float, lon: float, radius_m: int = 1000, max_pages: int = 2, **_: Any, ) -> list[ScrapedLot]: return lots monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) # DB mock: возвращает 2 строки для address-enrich запроса mock_db = _make_db_mock(["id1", "id2"]) # Title с полным адресом — должен быть извлечён через _extract_address_from_title fake_response = MagicMock() fake_response.status_code = 200 fake_response.text = ( "Продажа квартиры — Екатеринбург, улица Тестовая, 36 — id 123 " "на Яндекс.Недвижимости" ) # Мок curl_cffi AsyncSession как context manager mock_session = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) mock_session.get = AsyncMock(return_value=fake_response) import curl_cffi.requests as cffi_mod monkeypatch.setattr(cffi_mod, "AsyncSession", lambda **_kw: mock_session) fake = _FakeRuns() _install_runs(monkeypatch, fake) counters = await scrape_pipeline.run_yandex_city_sweep( db=mock_db, run_id=3, anchors=TEST_ANCHORS[:1], enrich_address=True, request_delay_sec=0.0, ) assert counters.address_attempted == 2 assert counters.address_enriched == 2 assert counters.address_failed == 0 assert fake.done is not None # ── address-enrich: per-item HTTP error не валит sweep ────────────────────── async def test_address_enrich_http_error_does_not_abort( monkeypatch: pytest.MonkeyPatch, ) -> None: """HTTP 403 при address-enrich → address_failed++, sweep продолжается.""" async def fake_fetch_multi( self: YandexRealtyScraper, lat: float, lon: float, radius_m: int = 1000, max_pages: int = 2, **_: Any, ) -> list[ScrapedLot]: return [_fake_lot("id-err")] monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) mock_db = _make_db_mock(["id-err"]) fake_response = MagicMock() fake_response.status_code = 403 fake_response.text = "" mock_session = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) mock_session.get = AsyncMock(return_value=fake_response) import curl_cffi.requests as cffi_mod monkeypatch.setattr(cffi_mod, "AsyncSession", lambda **_kw: mock_session) fake = _FakeRuns() _install_runs(monkeypatch, fake) counters = await scrape_pipeline.run_yandex_city_sweep( db=mock_db, run_id=4, anchors=TEST_ANCHORS[:1], enrich_address=True, request_delay_sec=0.0, ) assert counters.address_attempted == 1 assert counters.address_failed == 1 assert counters.address_enriched == 0 assert fake.done is not None # sweep завершился несмотря на ошибку # ── Mid-sweep cancel stops further anchors ────────────────────────────────── async def test_cancel_midway_stops_remaining_anchors( monkeypatch: pytest.MonkeyPatch, ) -> None: serp_calls: list[str] = [] async def fake_fetch_multi( self: YandexRealtyScraper, lat: float, lon: float, radius_m: int = 1000, max_pages: int = 2, **_: Any, ) -> list[ScrapedLot]: serp_calls.append(f"{lat}") return [_fake_lot(f"{lat}-0")] monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) # is_cancelled flips True on the 2nd check (before anchor #2) → only anchor #1 runs. fake = _FakeRuns(cancel_at_anchor=2) _install_runs(monkeypatch, fake) counters = await scrape_pipeline.run_yandex_city_sweep( db=MagicMock(), run_id=5, anchors=TEST_ANCHORS, pages_per_anchor=1, enrich_address=False, request_delay_sec=0.0, ) # Only the first anchor fetched before cancel. assert len(serp_calls) == 1 assert counters.anchors_done == 1 # Cancel path returns early: no mark_done, no ban/fail. assert fake.done is None assert fake.failed is None assert fake.banned is None # ── Consecutive failures → mark_banned + abort ────────────────────────────── async def test_consecutive_failures_abort_sweep( monkeypatch: pytest.MonkeyPatch, ) -> None: """All anchors raise → after MAX_CONSECUTIVE_FAILURES the sweep aborts + bans.""" attempts: list[str] = [] async def fake_fetch_fail( self: YandexRealtyScraper, lat: float, lon: float, radius_m: int = 1000, max_pages: int = 2, **_: Any, ) -> list[ScrapedLot]: attempts.append(f"{lat}") raise RuntimeError("yandex blocked") monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_fail) monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) fake = _FakeRuns() _install_runs(monkeypatch, fake) # 5 anchors so the 3-consecutive-failure threshold trips before the list ends. anchors = [ *TEST_ANCHORS, (56.7700, 60.5500, "A4"), (56.8650, 60.6200, "A5"), ] counters = await scrape_pipeline.run_yandex_city_sweep( db=MagicMock(), run_id=6, anchors=anchors, enrich_address=False, request_delay_sec=0.0, ) # Abort exactly at the threshold (3 consecutive failures). assert len(attempts) == scrape_pipeline.YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES assert counters.errors_count == scrape_pipeline.YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES assert counters.anchors_done == scrape_pipeline.YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES assert fake.banned is not None assert "consecutive" in fake.banned[0] assert fake.done is None # aborted, never marked done # ── Non-consecutive failures do not abort ─────────────────────────────────── async def test_non_consecutive_failures_do_not_abort( monkeypatch: pytest.MonkeyPatch, ) -> None: """A success between two failures resets the consecutive counter → no abort.""" def _name_of(lat: float, anchors: list[tuple[float, float, str]]) -> str: for a_lat, _lon, nm in anchors: if a_lat == lat: return nm return "?" anchors = [ *TEST_ANCHORS, (56.7700, 60.5500, "A4"), (56.8650, 60.6200, "A5"), ] failing = {"A1", "A3", "A5"} async def fake_fetch( self: YandexRealtyScraper, lat: float, lon: float, radius_m: int = 1000, max_pages: int = 2, **_: Any, ) -> list[ScrapedLot]: if _name_of(lat, anchors) in failing: raise RuntimeError("transient") return [_fake_lot(f"{lat}-0")] monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch) monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) fake = _FakeRuns() _install_runs(monkeypatch, fake) counters = await scrape_pipeline.run_yandex_city_sweep( db=MagicMock(), run_id=7, anchors=anchors, enrich_address=False, request_delay_sec=0.0, ) assert counters.errors_count == 3 # A1, A3, A5 assert counters.anchors_done == 5 # walked the whole list, no early abort assert counters.lots_fetched == 2 # A2 + A4 contributed assert fake.banned is None assert fake.done is not None # ── mark_done вызывается даже при пустом SERP ─────────────────────────────── async def test_mark_done_always_called( monkeypatch: pytest.MonkeyPatch, ) -> None: async def fake_fetch_empty( self: YandexRealtyScraper, lat: float, lon: float, radius_m: int = 1000, max_pages: int = 2, **_: Any, ) -> list[ScrapedLot]: return [] monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_empty) monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) fake = _FakeRuns() _install_runs(monkeypatch, fake) counters = await scrape_pipeline.run_yandex_city_sweep( db=MagicMock(), run_id=8, anchors=TEST_ANCHORS, enrich_address=False, request_delay_sec=0.0, ) assert fake.done is not None assert counters.lots_fetched == 0 assert counters.anchors_done == len(TEST_ANCHORS) # ── Admin endpoint tests (offline) ────────────────────────────────────────── @pytest.fixture def app_with_admin(): from fastapi import FastAPI from fastapi.testclient import TestClient from app.api.v1 import admin as admin_module from app.core.db import get_db app = FastAPI() app.include_router(admin_module.router, prefix="/api/v1/admin") def fake_db(): yield MagicMock() app.dependency_overrides[get_db] = fake_db return TestClient(app) def test_yandex_start_endpoint_validates_pages_per_anchor(app_with_admin) -> None: """pages_per_anchor=99 превышает max=10 → 422.""" r = app_with_admin.post( "/api/v1/admin/scrape/yandex-city-sweep", json={"pages_per_anchor": 99}, ) assert r.status_code == 422 def test_yandex_start_endpoint_validates_delay_too_low(app_with_admin) -> None: """request_delay_sec=1.0 меньше min=3.0 → 422.""" r = app_with_admin.post( "/api/v1/admin/scrape/yandex-city-sweep", json={"request_delay_sec": 1.0}, ) assert r.status_code == 422 def test_yandex_start_endpoint_ok(app_with_admin) -> None: """Valid request → 200, run_id в ответе.""" from unittest.mock import patch with ( patch("app.services.scrape_runs.create_run", return_value=55), patch("app.services.scrape_pipeline.run_yandex_city_sweep", new_callable=AsyncMock), ): r = app_with_admin.post( "/api/v1/admin/scrape/yandex-city-sweep", json={"pages_per_anchor": 2, "enrich_address": True}, ) assert r.status_code == 200 body = r.json() assert body["run_id"] == 55 assert body["status"] == "running" assert body["pages_per_anchor"] == 2 assert body["detail_top_n"] == 0 def test_yandex_cancel_endpoint(app_with_admin) -> None: """Cancel endpoint возвращает run_id + cancelled=True.""" from unittest.mock import patch with patch("app.services.scrape_runs.mark_cancelled", return_value=True): r = app_with_admin.post("/api/v1/admin/scrape/yandex-city-sweep/99/cancel") assert r.status_code == 200 body = r.json() assert body["run_id"] == 99 assert body["cancelled"] is True assert body["ok"] is True def test_yandex_cancel_endpoint_not_running(app_with_admin) -> None: """Cancel на не-running sweep → cancelled=False.""" from unittest.mock import patch with patch("app.services.scrape_runs.mark_cancelled", return_value=False): r = app_with_admin.post("/api/v1/admin/scrape/yandex-city-sweep/100/cancel") assert r.status_code == 200 assert r.json()["cancelled"] is False def test_yandex_list_runs_endpoint(app_with_admin) -> None: """List runs endpoint возвращает список.""" from datetime import UTC, datetime from unittest.mock import patch fake_rows = [ { "run_id": 20, "source": "yandex_city_sweep", "status": "done", "params": {"pages_per_anchor": 2, "enrich_address": True}, "counters": {"lots_fetched": 200, "address_enriched": 80}, "error": None, "started_at": datetime(2025, 5, 1, tzinfo=UTC), "finished_at": datetime(2025, 5, 1, 1, tzinfo=UTC), "heartbeat_at": datetime(2025, 5, 1, 1, tzinfo=UTC), } ] with patch("app.services.scrape_runs.list_recent", return_value=fake_rows): r = app_with_admin.get("/api/v1/admin/scrape/yandex-city-sweep/runs") assert r.status_code == 200 body = r.json() assert len(body) == 1 assert body[0]["run_id"] == 20 assert body[0]["source"] == "yandex_city_sweep" assert "2025-05-01" in body[0]["started_at"]