1058 lines
38 KiB
Python
1058 lines
38 KiB
Python
"""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.combos_skipped == 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 агрегируются.
|
||
|
||
FIX: fake_fetch_multi вызывает on_combo (инкрементальный save per-combo) —
|
||
имитирует реальное поведение scraper'а. save_listings должен вызываться
|
||
on_combo'м, а не одним батчем в конце anchor'а.
|
||
"""
|
||
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,
|
||
on_combo: Any = None,
|
||
**_: Any,
|
||
) -> list[ScrapedLot]:
|
||
serp_calls.append(f"{lat:.4f}")
|
||
lots = [_fake_lot(f"{lat}-{i}") for i in range(3)]
|
||
# Имитируем один combo — вызываем on_combo если предоставлен.
|
||
if on_combo is not None:
|
||
on_combo(f"vtorichka/combo_{lat:.4f}", lots)
|
||
return lots
|
||
|
||
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)
|
||
# save вызывается инкрементально через on_combo — по одному разу на anchor
|
||
# (fake имитирует один combo per anchor).
|
||
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="<title>test</title>")
|
||
|
||
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,
|
||
on_combo: Any = None,
|
||
**_: Any,
|
||
) -> list[ScrapedLot]:
|
||
# Вызываем on_combo чтобы anchor_lots накопился (инкрементальный save).
|
||
if on_combo is not None:
|
||
on_combo("vtorichka/combo_enrich_test", lots)
|
||
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 = (
|
||
"<title>Продажа квартиры — Екатеринбург, улица Тестовая, 36 — id 123 "
|
||
"на Яндекс.Недвижимости</title>"
|
||
)
|
||
|
||
# Мок 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,
|
||
on_combo: Any = None,
|
||
**_: Any,
|
||
) -> list[ScrapedLot]:
|
||
lots = [_fake_lot("id-err")]
|
||
if on_combo is not None:
|
||
on_combo("vtorichka/combo_http_err", lots)
|
||
return lots
|
||
|
||
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,
|
||
on_combo: Any = None,
|
||
**_: Any,
|
||
) -> list[ScrapedLot]:
|
||
if _name_of(lat, anchors) in failing:
|
||
raise RuntimeError("transient")
|
||
lots = [_fake_lot(f"{lat}-0")]
|
||
# Вызываем on_combo для инкрементального save.
|
||
if on_combo is not None:
|
||
on_combo(f"vtorichka/combo_{lat:.4f}", lots)
|
||
return lots
|
||
|
||
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"]
|
||
|
||
|
||
# ── Center-combos wiring: prod (anchors=None) uses EKB center + combos ──────
|
||
|
||
|
||
async def test_prod_mode_uses_center_anchor_with_combos(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""anchors=None (prod mode) → fetch_around_multi_room called with EKB center coords
|
||
AND rooms_list + price_ranges (combos mode), NOT with None/None (legacy mode).
|
||
anchors_total == 1 (single center sweep).
|
||
"""
|
||
from app.services.scrapers.yandex_realty import DEFAULT_PRICE_RANGES, ROOM_PATH
|
||
|
||
calls: list[dict] = []
|
||
|
||
async def fake_fetch_multi(
|
||
self: YandexRealtyScraper,
|
||
lat: float,
|
||
lon: float,
|
||
radius_m: int = 1000,
|
||
max_pages: int = 2,
|
||
rooms_list: list | None = None,
|
||
price_ranges: list | None = None,
|
||
on_combo: Any = None,
|
||
**_: Any,
|
||
) -> list[ScrapedLot]:
|
||
lots = [_fake_lot("center-1"), _fake_lot("center-2")]
|
||
calls.append(
|
||
{
|
||
"lat": lat,
|
||
"lon": lon,
|
||
"radius_m": radius_m,
|
||
"rooms_list": rooms_list,
|
||
"price_ranges": price_ranges,
|
||
"max_pages": max_pages,
|
||
}
|
||
)
|
||
# Имитируем on_combo (инкрементальный save) — один combo.
|
||
if on_combo is not None:
|
||
on_combo("vtorichka/center_combo", lots)
|
||
return lots
|
||
|
||
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=10,
|
||
# anchors=None → prod mode (EKB center)
|
||
pages_per_anchor=3,
|
||
enrich_address=False,
|
||
request_delay_sec=0.0,
|
||
)
|
||
|
||
# Single center anchor
|
||
assert counters.anchors_total == 1
|
||
assert counters.anchors_done == 1
|
||
|
||
# fetch_around_multi_room called exactly once
|
||
assert len(calls) == 1
|
||
call = calls[0]
|
||
|
||
# EKB center coords
|
||
assert abs(call["lat"] - 56.8400) < 0.0001
|
||
assert abs(call["lon"] - 60.6050) < 0.0001
|
||
|
||
# radius covers the whole city (25km default)
|
||
assert call["radius_m"] >= 10000
|
||
|
||
# Combos mode: rooms_list and price_ranges are NOT None
|
||
assert call["rooms_list"] is not None
|
||
assert call["price_ranges"] is not None
|
||
|
||
# rooms_list covers all ROOM_PATH keys
|
||
assert set(call["rooms_list"]) == set(ROOM_PATH.keys())
|
||
|
||
# price_ranges matches DEFAULT_PRICE_RANGES
|
||
assert call["price_ranges"] == DEFAULT_PRICE_RANGES
|
||
|
||
# max_pages forwarded from pages_per_anchor
|
||
assert call["max_pages"] == 3
|
||
|
||
# lots aggregated correctly via on_combo
|
||
assert counters.lots_fetched == 2
|
||
assert counters.lots_inserted == 2
|
||
assert fake.done is not None
|
||
assert fake.failed is None
|
||
|
||
|
||
async def test_explicit_anchors_still_works(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""anchors=[...] (test/manual override) → fetch called for each anchor, combos mode."""
|
||
calls: list[dict] = []
|
||
|
||
async def fake_fetch_multi(
|
||
self: YandexRealtyScraper,
|
||
lat: float,
|
||
lon: float,
|
||
radius_m: int = 1000,
|
||
max_pages: int = 2,
|
||
rooms_list: list | None = None,
|
||
price_ranges: list | None = None,
|
||
**_: Any,
|
||
) -> list[ScrapedLot]:
|
||
calls.append({"lat": lat, "rooms_list": rooms_list, "price_ranges": price_ranges})
|
||
return [_fake_lot(f"{lat}")]
|
||
|
||
monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi)
|
||
monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save)
|
||
fake = _FakeRuns()
|
||
_install_runs(monkeypatch, fake)
|
||
|
||
two_anchors = [(56.8400, 60.6050, "A"), (56.7950, 60.5300, "B")]
|
||
counters = await scrape_pipeline.run_yandex_city_sweep(
|
||
db=MagicMock(),
|
||
run_id=11,
|
||
anchors=two_anchors,
|
||
pages_per_anchor=2,
|
||
enrich_address=False,
|
||
request_delay_sec=0.0,
|
||
)
|
||
|
||
# Both anchors iterated
|
||
assert len(calls) == 2
|
||
assert counters.anchors_total == 2
|
||
assert counters.anchors_done == 2
|
||
|
||
# Each call uses combos (not None/None)
|
||
for c in calls:
|
||
assert c["rooms_list"] is not None
|
||
assert c["price_ranges"] is not None
|
||
|
||
assert fake.done is not None
|
||
|
||
|
||
# ── Watchdog timeout: combos vs explicit-anchor mode ───────────────────────
|
||
|
||
|
||
def test_combos_sweep_timeout_substantially_larger_than_anchor_timeout() -> None:
|
||
"""В combos/center mode (anchors=None) _sweep_timeout намного > ANCHOR_TIMEOUT_SEC.
|
||
|
||
Проверяет что вычисленный таймаут для дефолтных 30 combos × 3 pages > 1500s,
|
||
т.е. не равен ANCHOR_TIMEOUT_SEC=240 и обеспечивает полный прогон sweep'а.
|
||
Не нужны мокапы — только публичные константы и арифметика из модуля.
|
||
"""
|
||
from app.services.scrape_pipeline import (
|
||
_YANDEX_ADDRESS_ENRICH_BUDGET_S,
|
||
_YANDEX_COMBOS_PER_FETCH_S,
|
||
ANCHOR_TIMEOUT_SEC,
|
||
)
|
||
from app.services.scrapers.yandex_realty import DEFAULT_PRICE_RANGES, ROOM_PATH
|
||
|
||
# Параметры prod-режима
|
||
rooms_list = list(ROOM_PATH.keys()) # 5 комнатностей
|
||
price_ranges = DEFAULT_PRICE_RANGES # 6 ценовых диапазонов
|
||
max_pages = 3
|
||
request_delay_sec = 9.0 # продакшн default
|
||
|
||
num_combos = len(rooms_list) * len(price_ranges)
|
||
assert num_combos == 30, f"Expected 30 combos, got {num_combos}"
|
||
|
||
sweep_timeout = max(
|
||
ANCHOR_TIMEOUT_SEC,
|
||
num_combos * max_pages * (request_delay_sec + _YANDEX_COMBOS_PER_FETCH_S)
|
||
+ _YANDEX_ADDRESS_ENRICH_BUDGET_S,
|
||
)
|
||
|
||
# Должен быть существенно > 1500s (≈ 25 мин), что покрывает полный run.
|
||
assert sweep_timeout > 1500, (
|
||
f"sweep_timeout={sweep_timeout:.0f}s unexpectedly small — "
|
||
f"combos-mode will still timeout mid-sweep"
|
||
)
|
||
# И намного > ANCHOR_TIMEOUT_SEC (240s)
|
||
assert (
|
||
sweep_timeout > ANCHOR_TIMEOUT_SEC * 4
|
||
), f"sweep_timeout={sweep_timeout:.0f}s should be >> ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s"
|
||
|
||
|
||
async def test_explicit_anchor_mode_uses_anchor_timeout_sec(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""anchors=[...] (explicit override) → wait_for используется с ANCHOR_TIMEOUT_SEC.
|
||
|
||
Проверяет что в legacy/test режиме (explicit anchors) sweep_timeout не раздут
|
||
до combos-значения — он равен ANCHOR_TIMEOUT_SEC, т.к. каждый anchor маленький.
|
||
"""
|
||
import asyncio as _asyncio
|
||
|
||
from app.services.scrape_pipeline import ANCHOR_TIMEOUT_SEC
|
||
|
||
captured_timeouts: list[float] = []
|
||
|
||
_orig_wait_for = _asyncio.wait_for
|
||
|
||
async def _capturing_wait_for(coro, *, timeout): # type: ignore[no-untyped-def]
|
||
captured_timeouts.append(timeout)
|
||
# На самом деле запустить — мокнутый scraper вернёт [] мгновенно.
|
||
return await _orig_wait_for(coro, timeout=timeout)
|
||
|
||
monkeypatch.setattr(_asyncio, "wait_for", _capturing_wait_for)
|
||
|
||
async def fake_fetch_multi(
|
||
self: YandexRealtyScraper,
|
||
lat: float,
|
||
lon: float,
|
||
radius_m: int = 1000,
|
||
max_pages: int = 2,
|
||
**_: object,
|
||
) -> list[ScrapedLot]:
|
||
return []
|
||
|
||
monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi)
|
||
monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save)
|
||
fake = _FakeRuns()
|
||
_install_runs(monkeypatch, fake)
|
||
|
||
await scrape_pipeline.run_yandex_city_sweep(
|
||
db=MagicMock(),
|
||
run_id=20,
|
||
anchors=TEST_ANCHORS[:2], # explicit anchors — legacy mode
|
||
pages_per_anchor=2,
|
||
enrich_address=False,
|
||
request_delay_sec=0.0,
|
||
)
|
||
|
||
# В explicit-anchor режиме каждый anchor должен использовать ANCHOR_TIMEOUT_SEC
|
||
assert len(captured_timeouts) == 2
|
||
for t in captured_timeouts:
|
||
assert (
|
||
t == ANCHOR_TIMEOUT_SEC
|
||
), f"Expected ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s, got {t}s"
|
||
|
||
|
||
# ── FIX A: инкрементальный save per-combo ────────────────────────────────────
|
||
|
||
|
||
async def test_incremental_save_called_per_combo(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""save_listings вызывается on_combo per-combo, не одним батчем в конце anchor'а.
|
||
|
||
Имитируем 3 combo через on_combo — save_listings должен быть вызван 3 раза
|
||
(по одному на combo) с порциями лотов. Проверяем, что при краше anchor'а
|
||
уже сохранённые combo не теряются (данные в БД до finish anchor'а).
|
||
"""
|
||
save_batches: list[list[str]] = []
|
||
|
||
async def fake_fetch_multi(
|
||
self: YandexRealtyScraper,
|
||
lat: float,
|
||
lon: float,
|
||
radius_m: int = 1000,
|
||
max_pages: int = 2,
|
||
on_combo: Any = None,
|
||
**_: Any,
|
||
) -> list[ScrapedLot]:
|
||
all_lots = []
|
||
for combo_idx in range(3):
|
||
combo_lots = [_fake_lot(f"{lat}-combo{combo_idx}-lot0")]
|
||
all_lots.extend(combo_lots)
|
||
if on_combo is not None:
|
||
on_combo(f"vtorichka/combo_{combo_idx}", combo_lots)
|
||
return all_lots
|
||
|
||
def fake_save(
|
||
_db: Any, lots: list[ScrapedLot], *, run_id: int | None = None
|
||
) -> tuple[int, int]:
|
||
save_batches.append([lot.source_id for lot in lots if lot.source_id])
|
||
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=30,
|
||
anchors=TEST_ANCHORS[:1],
|
||
pages_per_anchor=2,
|
||
request_delay_sec=0.0,
|
||
enrich_address=False,
|
||
)
|
||
|
||
# save_listings вызван ровно 3 раза (по одному на combo), не одним батчем.
|
||
assert (
|
||
len(save_batches) == 3
|
||
), f"Ожидалось 3 save вызова (per-combo), получено {len(save_batches)}: {save_batches}"
|
||
# Каждый batch содержит ровно 1 лот (размер combo).
|
||
for batch in save_batches:
|
||
assert len(batch) == 1, f"Каждый combo-batch должен иметь 1 лот, получено {batch}"
|
||
|
||
# Суммарные counters корректны.
|
||
assert counters.lots_fetched == 3
|
||
assert counters.lots_inserted == 3
|
||
assert fake.done is not None
|
||
|
||
# heartbeat вызывался после каждого combo (через on_combo).
|
||
assert (
|
||
len(fake.heartbeats) >= 3
|
||
), f"Ожидалось ≥3 heartbeat обновлений (per-combo), получено {len(fake.heartbeats)}"
|
||
|
||
|
||
# ── FIX B: bounded retry — скипает combo после exhaustion ────────────────────
|
||
|
||
|
||
async def test_bounded_retry_combo_skipped_logged(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""on_combo НЕ вызывается если combo пропущен (все retry page-1 исчерпаны).
|
||
|
||
Имитируем sweep с двумя combos: первый скипается (on_combo не вызван),
|
||
второй успешен (on_combo вызван). Проверяем, что save_listings вызван
|
||
только для успешного combo, а не скипнутого.
|
||
"""
|
||
save_batches: list[list[str]] = []
|
||
on_combo_calls: list[str] = []
|
||
|
||
async def fake_fetch_multi(
|
||
self: YandexRealtyScraper,
|
||
lat: float,
|
||
lon: float,
|
||
radius_m: int = 1000,
|
||
max_pages: int = 2,
|
||
on_combo: Any = None,
|
||
**_: Any,
|
||
) -> list[ScrapedLot]:
|
||
# Combo 0: скипается — on_combo НЕ вызывается (симулирует exhausted retries).
|
||
# Combo 1: успешен — on_combo вызывается с лотом.
|
||
success_lot = _fake_lot(f"{lat}-success")
|
||
if on_combo is not None:
|
||
on_combo_calls.append("combo_1_success")
|
||
on_combo("vtorichka/combo_1", [success_lot])
|
||
return [success_lot]
|
||
|
||
def fake_save(
|
||
_db: Any, lots: list[ScrapedLot], *, run_id: int | None = None
|
||
) -> tuple[int, int]:
|
||
save_batches.append([lot.source_id for lot in lots if lot.source_id])
|
||
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=31,
|
||
anchors=TEST_ANCHORS[:1],
|
||
pages_per_anchor=2,
|
||
request_delay_sec=0.0,
|
||
enrich_address=False,
|
||
)
|
||
|
||
# Только успешный combo вызвал on_combo и save.
|
||
assert len(on_combo_calls) == 1
|
||
assert len(save_batches) == 1
|
||
assert counters.lots_fetched == 1
|
||
assert counters.lots_inserted == 1
|
||
assert fake.done is not None
|