"""Тесты exhaustive-загрузки Cian: fetch_all_secondary + _walk_price_range. Мокаем сетевые вызовы (_fetch_page_html, _extract_total_offers, sleep_between_requests) для детерминированного контроля поведения без реального HTTP. """ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.services.scrapers.base import ScrapedLot from app.services.scrapers.cian import _MAX_PRICE, _MIN_BRACKET, CianScraper def _make_lot(source_id: str, price: int = 3_000_000) -> ScrapedLot: """Минимальный ScrapedLot для теста.""" return ScrapedLot( source="cian", source_url=f"https://ekb.cian.ru/sale/flat/{source_id}/", source_id=source_id, address="Екатеринбург", price_rub=price, ) def _make_html_stub(total_offers: int, lot_ids: list[str]) -> str: """Заглушка HTML — не парсится напрямую; используется через мокнутые методы.""" return f"total={total_offers} ids={lot_ids}" @pytest.fixture def scraper() -> CianScraper: """CianScraper без реального __aenter__ (без HTTP warm-up).""" s = CianScraper.__new__(CianScraper) s.name = "cian" s.base_url = "https://ekb.cian.ru" s.request_delay_sec = 0.0 s._cffi = MagicMock() # не None → assert self._cffi is not None пройдёт return s @pytest.mark.asyncio async def test_fetch_all_secondary_splits_on_cap(scraper: CianScraper) -> None: """totalOffers > cap → бакет разбивается; в итоге оба суб-бакета пагинируются.""" # Настройка: rooms=(1,), один бакет [0, None] # Probe [0, None]: totalOffers=3000 > cap=1400 → split на [0, MAX_PRICE] # Probe [0, MAX_PRICE]: totalOffers=3000 > 1400 → split на [0, mid] и [mid+1, MAX_PRICE] # Probe [0, mid] (mid = MAX_PRICE//2 = 100_000_000): totalOffers=800 <= cap → пагинировать # Probe [mid+1, MAX_PRICE]: totalOffers=700 <= cap → пагинировать mid = _MAX_PRICE // 2 call_log: list[tuple] = [] async def fake_fetch_page_html( rooms: tuple, page: int, min_price: int | None, max_price: int | None ) -> str: call_log.append((rooms, page, min_price, max_price)) return f"page={page} min={min_price} max={max_price}" def fake_extract_total_offers(html: str) -> int | None: # Парсим min/max из fake html import re min_m = re.search(r"min=(\w+)", html) max_m = re.search(r"max=(\w+)", html) min_p = int(min_m.group(1)) if min_m and min_m.group(1) != "None" else 0 max_p = int(max_m.group(1)) if max_m and max_m.group(1) != "None" else _MAX_PRICE # Имитируем разделение: весь диапазон > 1400, левая и правая половины <= 1400 if max_p is None or max_p >= _MAX_PRICE: if min_p == 0: return 3000 # весь диапазон — нужно split if max_p == mid: return 800 # левая половина — пагинировать if min_p == mid + 1: return 700 # правая половина — пагинировать return 3000 # всё остальное — split def fake_parse_serp_html(html: str) -> list[ScrapedLot]: import re page_m = re.search(r"page=(\d+)", html) min_m = re.search(r"min=(\w+)", html) max_m = re.search(r"max=(\w+)", html) page = int(page_m.group(1)) if page_m else 1 min_p = min_m.group(1) if min_m else "0" max_p = max_m.group(1) if max_m else "MAX" # Возвращаем по 2 лота на первой странице каждого пагинируемого бакета if page == 1: return [ _make_lot(f"lot_{min_p}_{max_p}_p{page}_1"), _make_lot(f"lot_{min_p}_{max_p}_p{page}_2"), ] return [] # страница 2+ — пусто → early stop async def fake_sleep(*_: object, **__: object) -> None: pass with ( patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), patch.object(scraper, "_rotate_ip", return_value=False), ): lots = await scraper.fetch_all_secondary( rooms_buckets=[(1,)], price_cap_per_bucket=1400, ) # Должны получить лоты из ОБОИХ суб-бакетов после split assert len(lots) > 0, "Должны получить лоты после разделения бакета" # Проверяем что был probe с большим диапазоном И с sub-бакетами probed_ranges = [(min_p, max_p) for (_, page, min_p, max_p) in call_log if page == 1] # Должен быть probe [0, None] или [0, MAX] — начальный probe всего диапазона initial_probes = [r for r in probed_ranges if r[0] is None or r[0] == 0] assert ( len(initial_probes) >= 1 ), f"Ожидался probe всего диапазона, probed_ranges={probed_ranges}" @pytest.mark.asyncio async def test_fetch_all_secondary_paginates_when_under_cap(scraper: CianScraper) -> None: """totalOffers <= cap → пагинирует бакет полностью (страница за страницей).""" pages_fetched: list[int] = [] async def fake_fetch_page_html( rooms: tuple, page: int, min_price: int | None, max_price: int | None ) -> str: pages_fetched.append(page) return f"page={page}" def fake_extract_total_offers(html: str) -> int | None: return 56 # 56 офферов → ceil(56/28) = 2 страницы def fake_parse_serp_html(html: str) -> list[ScrapedLot]: import re page_m = re.search(r"page=(\d+)", html) page = int(page_m.group(1)) if page_m else 1 if page <= 2: return [_make_lot(f"lot_p{page}_{i}") for i in range(28)] return [] with ( patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), patch.object(scraper, "_rotate_ip", return_value=False), ): lots = await scraper.fetch_all_secondary( rooms_buckets=[(2,)], price_cap_per_bucket=1400, ) # 28 лотов на стр 1 + 28 на стр 2 = 56, дедуп по source_id assert len(lots) == 56, f"Ожидалось 56 уникальных лотов, получено {len(lots)}" # Должно быть 2 страницы пагинации (page=1 probe + page=2) assert 2 in pages_fetched, "Должна быть запрошена страница 2" @pytest.mark.asyncio async def test_fetch_all_secondary_min_bracket_guard(scraper: CianScraper) -> None: """Если hi - lo < MIN_BRACKET → пагинируем как есть (не делим бесконечно).""" call_count = 0 async def fake_fetch_page_html( rooms: tuple, page: int, min_price: int | None, max_price: int | None ) -> str: nonlocal call_count call_count += 1 if call_count > 100: raise RuntimeError("Бесконечная рекурсия — MIN_BRACKET guard не сработал") return f"page={page}" def fake_extract_total_offers(html: str) -> int | None: # Всегда > cap → без guard делилось бы бесконечно return 5000 def fake_parse_serp_html(html: str) -> list[ScrapedLot]: return [] # пустой → early stop в пагинации with ( patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), patch.object(scraper, "_rotate_ip", return_value=False), ): # Запускаем с очень узким диапазоном — guard должен остановить деление await scraper._walk_price_range( rooms=(1,), lo=1_000_000, hi=1_000_000 + _MIN_BRACKET - 1, # bracket < MIN_BRACKET seen={}, price_cap_per_bucket=1400, max_pages_per_bucket=54, ) # Тест прошёл если нет RuntimeError (guard сработал) assert call_count <= 10, f"Слишком много вызовов ({call_count}) — guard не сработал"