"""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))