"""Unit-тесты для DomClick JSON API scraper (Layer A rewrite).
Offline, без сети и БД. Монкейпатчим BrowserFetcher / _fetch_json на canned JSON.
Coverage:
- _offer_to_lot: поля ScrapedLot (source_id, source_url, lat/lon, address,
rooms, area, floor, total_floors, year_built, price, listing_segment, source)
- Studio bucket (room_token="st"): rooms=0
- Гео-guard: Челябинск оффер отбрасывается, EKB оффер проходит
- Block detection: не-JSON body → DomClickBlockedError
- URL building: rooms=5%2B в URL; sale_price__gte/lte в banded URL;
offset/limit в list URL и отсутствует в count URL
- Bucketing: snippetsCount>2000 → рекурсивный split; offset не достигает 2000
"""
from __future__ import annotations
import json
import os
from datetime import date
from urllib.parse import parse_qs, urlparse
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.scrapers.domclick import DomClickScraper, _is_ekb, _parse_publish_date
from app.services.scrapers.domclick_exceptions import DomClickBlockedError
# ── Фикстуры ─────────────────────────────────────────────────────────────────
def _make_scraper() -> DomClickScraper:
"""Создаём DomClickScraper без DB (монкейпатчим get_scraper_delay)."""
from unittest.mock import patch
with patch("app.services.scrapers.domclick.get_scraper_delay", return_value=0.0):
scraper = DomClickScraper()
scraper._browser = object() # не None — assertion в _fetch_json проверяет
return scraper
_SAMPLE_OFFER: dict = {
"id": 2075671636,
"path": "/card/sale__flat__2075671636",
"location": {"lat": 56.83, "lon": 60.61},
"address": {"displayName": "Екатеринбург, улица Ленина, 10"},
"objectInfo": {
"area": 52.3,
"rooms": 2,
"floor": 5,
"isApartment": False,
},
"house": {"floors": 16, "buildYear": 2005},
"price": 6500000,
"squarePrice": 124283,
"flatComplex": {"id": 123, "name": "ЖК Тест", "slug": "zhk-test"},
"isRosreestrApproved": True,
"publishedDate": "2026-06-01",
"updatedDate": "2026-06-15",
"lastPriceHistoryState": None,
"offerRegionName": "Екатеринбург",
}
_STUDIO_OFFER: dict = {
"id": 9001,
"path": "/card/sale__flat__9001",
"location": {"lat": 56.84, "lon": 60.60},
"address": {"displayName": "Екатеринбург, пр-т Ленина, 50"},
"objectInfo": {"area": 24.5, "rooms": None, "floor": 3, "isApartment": False},
"house": {"floors": 10, "buildYear": 2020},
"price": 3100000,
"squarePrice": 126531,
"flatComplex": None,
"isRosreestrApproved": False,
"publishedDate": None,
"updatedDate": None,
"lastPriceHistoryState": None,
"offerRegionName": "Екатеринбург",
}
_CHELYABINSK_OFFER: dict = {
"id": 99999,
"path": "/card/sale__flat__99999",
"location": {"lat": 55.15, "lon": 61.39}, # вне EKB bbox
"address": {"displayName": "Челябинск, улица Ленина, 1"},
"objectInfo": {"area": 40.0, "rooms": 1, "floor": 3, "isApartment": False},
"house": {"floors": 9, "buildYear": 1990},
"price": 3000000,
"squarePrice": 75000,
"flatComplex": None,
"isRosreestrApproved": False,
"publishedDate": None,
"updatedDate": None,
"lastPriceHistoryState": None,
"offerRegionName": "Челябинск", # не Екатеринбург
}
# ── _offer_to_lot — базовый маппинг ──────────────────────────────────────────
def test_offer_to_lot_basic_fields() -> None:
"""Репрезентативный оффер → ScrapedLot с корректными полями."""
scraper = _make_scraper()
lot = scraper._offer_to_lot(_SAMPLE_OFFER, room_token="2")
assert lot is not None
assert lot.source == "domklik"
assert lot.source_id == "2075671636"
assert lot.source_url == "https://domclick.ru/card/sale__flat__2075671636"
assert lot.lat == pytest.approx(56.83)
assert lot.lon == pytest.approx(60.61)
assert lot.address == "Екатеринбург, улица Ленина, 10"
assert lot.rooms == 2
assert lot.area_m2 == pytest.approx(52.3)
assert lot.floor == 5
assert lot.total_floors == 16
assert lot.year_built == 2005
assert lot.price_rub == 6_500_000
assert lot.price_per_m2 == 124_283
assert lot.listing_segment == "vtorichka"
assert lot.listing_date == date(2026, 6, 1)
assert lot.publish_date == date(2026, 6, 1)
assert lot.raw_payload is not None
assert lot.raw_payload["is_rosreestr_approved"] is True
assert lot.raw_payload["flat_complex"]["name"] == "ЖК Тест"
def test_offer_to_lot_studio_rooms_zero() -> None:
"""Studio bucket (room_token='st') → rooms=0 независимо от objectInfo.rooms."""
scraper = _make_scraper()
lot = scraper._offer_to_lot(_STUDIO_OFFER, room_token="st")
assert lot is not None
assert lot.rooms == 0
assert lot.area_m2 == pytest.approx(24.5)
assert lot.price_rub == 3_100_000
# publishedDate=None → None
assert lot.listing_date is None
def test_offer_to_lot_missing_price_returns_none() -> None:
"""Оффер без цены → None."""
scraper = _make_scraper()
offer = {**_SAMPLE_OFFER, "price": None}
assert scraper._offer_to_lot(offer, "2") is None
def test_offer_to_lot_zero_price_returns_none() -> None:
"""Оффер с price=0 → None."""
scraper = _make_scraper()
offer = {**_SAMPLE_OFFER, "price": 0}
assert scraper._offer_to_lot(offer, "2") is None
def test_offer_to_lot_missing_id_returns_none() -> None:
"""Оффер без id → None."""
scraper = _make_scraper()
offer = {k: v for k, v in _SAMPLE_OFFER.items() if k != "id"}
assert scraper._offer_to_lot(offer, "2") is None
# ── Гео-guard ─────────────────────────────────────────────────────────────────
def test_geo_guard_ekb_by_region_name() -> None:
"""offerRegionName='Екатеринбург' → EKB."""
assert _is_ekb({"offerRegionName": "Екатеринбург"}) is True
def test_geo_guard_ekb_by_bbox_coords() -> None:
"""Координаты внутри EKB bbox → EKB (даже без offerRegionName)."""
assert _is_ekb({"location": {"lat": 56.83, "lon": 60.61}}) is True
def test_geo_guard_chelyabinsk_dropped() -> None:
"""Челябинск (имя и bbox) → не EKB."""
assert _is_ekb(_CHELYABINSK_OFFER) is False
def test_geo_guard_out_of_bbox_no_name() -> None:
"""Координаты вне EKB bbox и нет offerRegionName → не EKB."""
assert _is_ekb({"location": {"lat": 55.0, "lon": 73.0}}) is False
def test_parse_page_drops_non_ekb() -> None:
"""_parse_page: Челябинск оффер отброшен, EKB проходит."""
scraper = _make_scraper()
dropped_ref = [0]
items = [_SAMPLE_OFFER, _CHELYABINSK_OFFER]
lots = scraper._parse_page(items, "2", dropped_ref)
assert len(lots) == 1
assert lots[0].source_id == "2075671636"
assert dropped_ref[0] == 1 # один дроп
# ── Block detection ───────────────────────────────────────────────────────────
async def test_fetch_json_html_body_raises_blocked(monkeypatch: pytest.MonkeyPatch) -> None:
"""Если BrowserFetcher.fetch возвращает HTML → DomClickBlockedError."""
class _FakeBrowser:
async def fetch(self, url: str) -> str:
return "
QRATOR protection"
scraper = _make_scraper()
scraper._browser = _FakeBrowser()
with pytest.raises(DomClickBlockedError):
await scraper._fetch_json("https://bff-search-web.domclick.ru/api/offers/count/v1?x=1")
async def test_fetch_json_non_json_body_raises_blocked(monkeypatch: pytest.MonkeyPatch) -> None:
"""Не-JSON body (не HTML, просто мусор) → DomClickBlockedError."""
class _FakeBrowser:
async def fetch(self, url: str) -> str:
return "not valid json at all |||"
scraper = _make_scraper()
scraper._browser = _FakeBrowser()
with pytest.raises(DomClickBlockedError):
await scraper._fetch_json("https://bff-search-web.domclick.ru/api/offers/count/v1?x=1")
async def test_fetch_json_valid_json_returns_dict(monkeypatch: pytest.MonkeyPatch) -> None:
"""Валидный JSON → dict без исключений."""
class _FakeBrowser:
async def fetch(self, url: str) -> str:
return json.dumps({"result": {"snippetsCount": 42}})
scraper = _make_scraper()
scraper._browser = _FakeBrowser()
data = await scraper._fetch_json("https://bff-search-web.domclick.ru/api/offers/count/v1?x=1")
assert data["result"]["snippetsCount"] == 42
# ── URL building ──────────────────────────────────────────────────────────────
def test_build_count_url_no_offset_limit() -> None:
"""count URL не содержит offset и limit."""
scraper = _make_scraper()
url = scraper._build_count_url("2", lo=0, hi=None)
qs = parse_qs(urlparse(url).query)
assert "offset" not in qs
assert "limit" not in qs
assert "/count/v1" in url
def test_build_list_url_has_offset_limit() -> None:
"""list URL содержит offset и limit."""
scraper = _make_scraper()
url = scraper._build_list_url("2", lo=0, hi=None, offset=40)
qs = parse_qs(urlparse(url).query)
assert qs["offset"] == ["40"]
assert qs["limit"] == ["20"]
assert "/offers/v1" in url
def test_build_list_url_5plus_encoded() -> None:
"""rooms=5+ корректно URL-кодируется (5%2B или эквивалент)."""
scraper = _make_scraper()
url = scraper._build_list_url("5+", lo=0, hi=None, offset=0)
# urlencode кодирует '+' как '%2B в query string
assert "rooms=5%2B" in url or "rooms=5+" in url # оба варианта корректны
# Проверяем через parse_qs
qs = parse_qs(urlparse(url).query)
assert qs.get("rooms") == ["5+"]
def test_build_list_url_price_band() -> None:
"""Бандовый URL содержит sale_price__gte и sale_price__lte."""
scraper = _make_scraper()
url = scraper._build_list_url("2", lo=2_000_000, hi=5_000_000, offset=0)
qs = parse_qs(urlparse(url).query)
assert qs["sale_price__gte"] == ["2000000"]
assert qs["sale_price__lte"] == ["5000000"]
def test_build_list_url_no_price_params_when_open() -> None:
"""Открытый бакет (lo=0, hi=None) → нет ценовых параметров."""
scraper = _make_scraper()
url = scraper._build_list_url("1", lo=0, hi=None, offset=0)
qs = parse_qs(urlparse(url).query)
assert "sale_price__gte" not in qs
assert "sale_price__lte" not in qs
def test_build_url_contains_ekb_params() -> None:
"""URL содержит EKB region GUID и aids."""
scraper = _make_scraper()
url = scraper._build_count_url("1", lo=0, hi=None)
assert "0d475b79-88de-4054-818c-37d8f9d0d440" in url
qs = parse_qs(urlparse(url).query)
assert qs["aids"] == ["20561"]
assert qs["deal_type"] == ["sale"]
# ── Bucketing — split при snippetsCount > 2000 ────────────────────────────────
async def test_bucketing_split_on_overflow(monkeypatch: pytest.MonkeyPatch) -> None:
"""snippetsCount>2000 для открытого бакета → split; offset never ≥ 2000."""
call_log: list[str] = []
def _make_ekb_offer(offer_id: str) -> dict:
return {
"id": offer_id,
"path": f"/card/sale__flat__{offer_id}",
"location": {"lat": 56.83, "lon": 60.61},
"address": {"displayName": "Екатеринбург, ул. Тест, 1"},
"objectInfo": {"area": 45.0, "rooms": 1, "floor": 3, "isApartment": False},
"house": {"floors": 9, "buildYear": 2000},
"price": 4000000,
"squarePrice": 88888,
"flatComplex": None,
"isRosreestrApproved": False,
"publishedDate": None,
"updatedDate": None,
"lastPriceHistoryState": None,
"offerRegionName": "Екатеринбург",
}
async def mock_fetch_json(self: DomClickScraper, url: str) -> dict:
call_log.append(url)
parsed = urlparse(url)
qs = parse_qs(parsed.query)
if "/count/v1" in url:
has_gte = "sale_price__gte" in qs
has_lte = "sale_price__lte" in qs
if not has_gte and not has_lte:
# Открытый бакет [0, None] → overflow, trigger split
return {"result": {"snippetsCount": 2001, "offersCount": 2001}}
elif has_lte and int(qs["sale_price__lte"][0]) == 30_000_000:
# [0, 30M] → within cap
return {"result": {"snippetsCount": 20, "offersCount": 20}}
else:
# Хвост [30M+1, None] → empty
return {"result": {"snippetsCount": 0, "offersCount": 0}}
# list URL
if "/offers/v1" in url:
offset_val = int(qs.get("offset", ["0"])[0])
if offset_val == 0:
return {"result": {"items": [_make_ekb_offer("101")]}}
return {"result": {"items": []}}
return {"result": {}}
import asyncio as _asyncio
monkeypatch.setattr(DomClickScraper, "_fetch_json", mock_fetch_json)
# sleep_between_requests is inherited from BaseScraper → shadow on subclass
monkeypatch.setattr(DomClickScraper, "sleep_between_requests", lambda self: _asyncio.sleep(0))
from unittest.mock import patch
with patch("app.services.scrapers.domclick.get_scraper_delay", return_value=0.0):
scraper = DomClickScraper()
scraper._browser = object() # не None
seen: dict = {}
geo_ref = [0]
await scraper._walk_price_range(
room_token="1",
lo=0,
hi=None,
seen=seen,
pages=100,
geo_dropped_ref=geo_ref,
)
# Split произошёл: как минимум 2 count-вызова (open band + [0, 30M])
count_urls = [u for u in call_log if "/count/v1" in u]
assert len(count_urls) >= 2, f"Expected split, got count_urls={count_urls}"
# Ни один list-запрос не должен иметь offset >= 2000
list_urls = [u for u in call_log if "/offers/v1" in u]
for lu in list_urls:
offset_val = int(parse_qs(urlparse(lu).query).get("offset", ["0"])[0])
assert offset_val < 2000, f"offset={offset_val} >= OFFSET_CAP in url={lu}"
# Лот прошёл и дедуплицирован
assert "101" in seen
# ── _parse_publish_date ───────────────────────────────────────────────────────
@pytest.mark.parametrize(
"val, expected",
[
("2026-06-01", date(2026, 6, 1)),
("2026-06-01T12:00:00Z", date(2026, 6, 1)),
(1748736000, date(2025, 6, 1)), # 2025-06-01 00:00 UTC epoch
("1748736000", date(2025, 6, 1)),
(None, None),
("invalid-date", None),
("", None),
],
)
def test_parse_publish_date(val: object, expected: date | None) -> None:
result = _parse_publish_date(val)
assert result == expected
# ── Scraper meta ─────────────────────────────────────────────────────────────
def test_scraper_attributes() -> None:
scraper = _make_scraper()
assert scraper.name == "domklik"
assert scraper.source == "domklik"
assert scraper.base_url == "https://bff-search-web.domclick.ru"
assert scraper.request_delay_sec >= 0.0
assert scraper.parse_failures == 0
def test_fetch_around_raises() -> None:
import asyncio
scraper = _make_scraper()
with pytest.raises(NotImplementedError, match="geo-radius"):
asyncio.run(scraper.fetch_around(56.8, 60.6))