372 lines
14 KiB
Python
372 lines
14 KiB
Python
"""Offline unit tests for run_yandex_city_sweep (#561, shipped DORMANT).
|
||
|
||
Pure & fast: NO live network, NO DB. We monkeypatch:
|
||
- YandexRealtyScraper.__init__ / __aenter__ / __aexit__ — avoid get_scraper_delay
|
||
DB read + real httpx.AsyncClient creation.
|
||
- YandexRealtyScraper.fetch_around — 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.
|
||
|
||
These tests assert orchestration semantics only; live Yandex behavior is intentionally
|
||
unverified (sweep is dormant until egress-IP rotation #623).
|
||
"""
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from typing import Any
|
||
|
||
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 "anchor k cancels/fails, others proceed".
|
||
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="Екатеринбург, улица Тестовая, 1",
|
||
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 httpx."""
|
||
|
||
def _init(self: YandexRealtyScraper, city: str = "ekaterinburg") -> None:
|
||
self.city = city
|
||
self.request_delay_sec = 0.0
|
||
self._client = None
|
||
|
||
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)
|
||
|
||
|
||
# ── Happy path: all anchors iterated, counters aggregated ───────────────────
|
||
|
||
|
||
async def test_sweep_iterates_all_anchors_and_aggregates(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
fetch_calls: list[tuple[float, float, int]] = []
|
||
save_calls: list[int] = []
|
||
|
||
async def fake_fetch(
|
||
self: YandexRealtyScraper,
|
||
lat: float,
|
||
lon: float,
|
||
radius_m: int = 1000,
|
||
page: int = 0,
|
||
) -> list[ScrapedLot]:
|
||
fetch_calls.append((lat, lon, page))
|
||
# 2 lots per page; let pagination run the full pages_per_anchor.
|
||
return [_fake_lot(f"{lat}-{page}-{i}") for i in range(2)]
|
||
|
||
def fake_save(
|
||
_db: Any, lots: list[ScrapedLot], *, run_id: int | None = None
|
||
) -> tuple[int, int]:
|
||
save_calls.append(len(lots))
|
||
# Pretend everything is a fresh insert.
|
||
return len(lots), 0
|
||
|
||
monkeypatch.setattr(YandexRealtyScraper, "fetch_around", 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=object(),
|
||
run_id=1,
|
||
anchors=TEST_ANCHORS,
|
||
pages_per_anchor=2,
|
||
request_delay_sec=0.0,
|
||
)
|
||
|
||
# fetch_around called pages_per_anchor times per anchor (no empty-page short-circuit).
|
||
assert len(fetch_calls) == len(TEST_ANCHORS) * 2
|
||
# save_listings called once per anchor (anchor-level batch).
|
||
assert len(save_calls) == len(TEST_ANCHORS)
|
||
# Counters: 3 anchors × 2 pages × 2 lots = 12.
|
||
assert counters.anchors_total == len(TEST_ANCHORS)
|
||
assert counters.anchors_done == len(TEST_ANCHORS)
|
||
assert counters.pages_fetched == len(TEST_ANCHORS) * 2
|
||
assert counters.lots_fetched == 12
|
||
assert counters.lots_inserted == 12
|
||
assert counters.lots_updated == 0
|
||
assert counters.errors_count == 0
|
||
# mark_done called with final counters; no failure/ban.
|
||
assert fake.done is not None
|
||
assert fake.done["lots_fetched"] == 12
|
||
assert fake.failed is None
|
||
assert fake.banned is None
|
||
|
||
|
||
async def test_save_listings_receives_run_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""run_id must be threaded into save_listings for snapshot provenance."""
|
||
seen_run_ids: list[int | None] = []
|
||
|
||
async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0):
|
||
return [_fake_lot(f"{lat}-{page}")]
|
||
|
||
def fake_save(_db, lots, *, run_id=None):
|
||
seen_run_ids.append(run_id)
|
||
return len(lots), 0
|
||
|
||
monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
|
||
monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save)
|
||
_install_runs(monkeypatch, _FakeRuns())
|
||
|
||
await scrape_pipeline.run_yandex_city_sweep(
|
||
db=object(), run_id=777, anchors=TEST_ANCHORS, pages_per_anchor=1,
|
||
)
|
||
assert seen_run_ids == [777, 777, 777]
|
||
|
||
|
||
# ── Empty page short-circuits pagination ────────────────────────────────────
|
||
|
||
|
||
async def test_empty_page_stops_pagination(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""An empty SERP page ends pagination for that anchor (no further pages)."""
|
||
pages_seen: list[int] = []
|
||
|
||
async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0):
|
||
pages_seen.append(page)
|
||
if page == 0:
|
||
return [_fake_lot(f"{lat}-0")]
|
||
return [] # page 1 empty → stop
|
||
|
||
monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
|
||
monkeypatch.setattr(
|
||
scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0)
|
||
)
|
||
fake = _FakeRuns()
|
||
_install_runs(monkeypatch, fake)
|
||
|
||
counters = await scrape_pipeline.run_yandex_city_sweep(
|
||
db=object(), run_id=2, anchors=TEST_ANCHORS, pages_per_anchor=5,
|
||
)
|
||
# Per anchor: page 0 (1 lot) then page 1 (empty, break) → 2 pages each.
|
||
assert pages_seen == [0, 1, 0, 1, 0, 1]
|
||
assert counters.pages_fetched == 6 # 2 per anchor × 3
|
||
assert counters.lots_fetched == 3 # 1 per anchor
|
||
assert counters.anchors_done == 3
|
||
|
||
|
||
# ── Mid-sweep cancel stops further anchors ──────────────────────────────────
|
||
|
||
|
||
async def test_cancel_midway_stops_remaining_anchors(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
fetch_calls: list[str] = []
|
||
|
||
async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0):
|
||
fetch_calls.append(f"{lat}")
|
||
return [_fake_lot(f"{lat}-{page}")]
|
||
|
||
monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
|
||
monkeypatch.setattr(
|
||
scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0)
|
||
)
|
||
# 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=object(), run_id=3, anchors=TEST_ANCHORS, pages_per_anchor=1,
|
||
)
|
||
# Only the first anchor fetched before cancel.
|
||
assert len(fetch_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
|
||
|
||
|
||
# ── Per-anchor exception is isolated (single failure → continue) ────────────
|
||
|
||
|
||
async def test_single_anchor_failure_is_isolated(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Anchor #2 raises → anchors #1 and #3 still processed; sweep completes."""
|
||
processed: list[str] = []
|
||
|
||
async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0):
|
||
if name_of(lat) == "A2":
|
||
raise RuntimeError("boom on anchor 2")
|
||
processed.append(name_of(lat))
|
||
return [_fake_lot(f"{lat}-{page}")]
|
||
|
||
def name_of(lat: float) -> str:
|
||
for a_lat, _lon, nm in TEST_ANCHORS:
|
||
if a_lat == lat:
|
||
return nm
|
||
return "?"
|
||
|
||
monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
|
||
monkeypatch.setattr(
|
||
scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0)
|
||
)
|
||
fake = _FakeRuns()
|
||
_install_runs(monkeypatch, fake)
|
||
|
||
counters = await scrape_pipeline.run_yandex_city_sweep(
|
||
db=object(), run_id=4, anchors=TEST_ANCHORS, pages_per_anchor=1,
|
||
)
|
||
# A1 and A3 processed; A2 raised and was skipped.
|
||
assert processed == ["A1", "A3"]
|
||
assert counters.errors_count == 1
|
||
assert counters.anchors_done == 3 # loop advanced past all 3 anchors
|
||
assert counters.lots_fetched == 2 # only A1 + A3 contributed lots
|
||
# Not an abort — sweep marked done despite the isolated failure.
|
||
assert fake.done is not None
|
||
assert fake.banned is None
|
||
|
||
|
||
# ── Defensive abort: N consecutive failures → mark_banned, stop early ───────
|
||
|
||
|
||
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(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0):
|
||
attempts.append(f"{lat}")
|
||
raise RuntimeError("yandex blocked")
|
||
|
||
monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
|
||
monkeypatch.setattr(
|
||
scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0)
|
||
)
|
||
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=object(), run_id=5, anchors=anchors, pages_per_anchor=1,
|
||
)
|
||
# Abort exactly at the threshold (3 consecutive failures), not all 5 anchors.
|
||
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
|
||
|
||
|
||
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"),
|
||
]
|
||
# Fail A1, succeed A2, fail A3, succeed A4, fail A5 → never 3-in-a-row.
|
||
failing = {"A1", "A3", "A5"}
|
||
|
||
async def fake_fetch(self: YandexRealtyScraper, lat, lon, radius_m=1000, page=0):
|
||
if name_of(lat, anchors) in failing:
|
||
raise RuntimeError("transient")
|
||
return [_fake_lot(f"{lat}-{page}")]
|
||
|
||
monkeypatch.setattr(YandexRealtyScraper, "fetch_around", fake_fetch)
|
||
monkeypatch.setattr(
|
||
scrape_pipeline, "save_listings", lambda _db, lots, *, run_id=None: (len(lots), 0)
|
||
)
|
||
fake = _FakeRuns()
|
||
_install_runs(monkeypatch, fake)
|
||
|
||
counters = await scrape_pipeline.run_yandex_city_sweep(
|
||
db=object(), run_id=6, anchors=anchors, pages_per_anchor=1,
|
||
)
|
||
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
|