From 235cc3065ea8e6c3354d3f0a2a0f1a98c46b499d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 6 Sep 2026 02:38:12 +0500 Subject: [PATCH] =?UTF-8?q?test(#3375):=20=D1=81=D1=82=D0=B0=D0=B1=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=85=D0=BE=D0=B4=D0=B8=D1=82=20=D0=B4=D0=BE=20=5F?= =?UTF-8?q?degraded=20=D0=B8=20=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B0;=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B9=20=D0=BF=D1=80=D0=BE=20?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D1=81=D0=BD=D0=BE=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_scraper_kit_pipeline_parity2.py | 72 ++++++++++++++++--- 1 file changed, 61 insertions(+), 11 deletions(-) diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py index ebc1d3a1..aaa1db08 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -640,20 +640,45 @@ async def test_full_load_smoke(source: str) -> None: # список — а yandex/serp.py звал `on_bucket(bucket_key, len(seen), complete)` (int), # и `_on_bucket` отдавал это в `save_listings` (`for lot in lots`) → TypeError: # ручной full-load Яндекса не сохранял ничего. Ниже гоняется НАСТОЯЩИЙ -# YandexRealtyScraper (fetch_all_secondary → _walk_price_range → _leaf), подменён -# только gate-JSON транспорт. Фальсификация: вернуть в serp.py `len(seen)` — тест -# краснеет TypeError'ом на save_listings, как прод. +# YandexRealtyScraper (fetch_all_secondary → _walk_price_range → _probe/_leaf/ +# _degraded), подменён только gate-JSON транспорт. +# +# on_bucket в serp.py зовётся из ТРЁХ мест, и стаб обязан доходить до каждого, +# иначе фальсификация двух из них останется зелёной (ревью PR #3378): +# * _degraded — брекет `_DEGRADED_LO` (probe отдаёт None, _rotate_ip=False); +# * leaf одностраничный — все прочие брекеты (totalItems=1); +# * leaf многостраничный — брекет `_MULTIPAGE_LO` (totalItems требует 3 страницы). +# Фальсификация: вернуть в serp.py len(seen) — тест краснеет TypeError'ом на +# `counters.unique_fetched += len(lots)` (save_listings здесь мок и int проглатывает; +# в проде падает сам save_listings). + +# Сид-брекеты берутся из get_price_seed_brackets() — эти два существуют в сетке ЕКБ. +_MULTIPAGE_LO = 4_000_000 # totalItems=45 при _GATE_PAGE_SIZE=20 → 3 страницы +_DEGRADED_LO = 8_000_000 # probe этого брекета проваливается → DEGRADE-политика +_MULTIPAGE_TOTAL = 45 +_MULTIPAGE_PAGES = 3 class _StubbedYandexScraper(YandexRealtyScraper): """Настоящий скрапер яндекса, у которого замокан ТОЛЬКО gate-JSON транспорт.""" + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + # Брекеты, чей probe уже провалился: повторный запрос (он приходит уже из + # _degraded) отдаёт данные. Иначе degraded-бакет пришёл бы пустым и + # pipeline._on_bucket вернулся бы на `if not lots` до save_listings. + self._probe_failed: set[int | None] = set() + async def __aenter__(self) -> _StubbedYandexScraper: return self # без camoufox/BrowserFetcher async def __aexit__(self, *_exc: Any) -> bool: return False + async def _rotate_ip(self) -> bool: + """Ротация не спасает → probe-fail доходит до DEGRADE, а не до retry-успеха.""" + return False + async def fetch_all_secondary(self, **kwargs: Any) -> list[Any]: """Одна комнатность вместо пяти — путь до `_leaf` тот же, прогон короче.""" return await super().fetch_all_secondary(rooms_buckets=["2"], **kwargs) @@ -665,15 +690,25 @@ class _StubbedYandexScraper(YandexRealtyScraper): price_min: int | None = None, price_max: int | None = None, new_flat: str = "NO", - ) -> dict[str, Any]: - """totalItems=1 → бисекция не делит, leaf сразу. Оффер уникален на бакет.""" - offer_id = f"y{price_min or 0}_{price_max or 0}" + ) -> dict[str, Any] | None: + """Ответ гейта, зависящий от брекета — см. комментарий над классом.""" + if price_min == _DEGRADED_LO and price_min not in self._probe_failed: + self._probe_failed.add(price_min) + return None # probe провалился; _degraded ниже уже пагинирует по данным + multipage = price_min == _MULTIPAGE_LO + total = _MULTIPAGE_TOTAL if multipage else 1 + pages = _MULTIPAGE_PAGES if multipage else 1 + # Degraded-ветка пагинирует до пустоты — вторая страница обрывает цикл. + entities: list[dict[str, Any]] = [] + if page <= pages: + offer_id = f"y{price_min or 0}_{price_max or 0}_p{page}" + entities = [{"offerId": offer_id, "price": {"value": 5_000_000}}] return { "response": { "search": { "offers": { - "entities": [{"offerId": offer_id, "price": {"value": 5_000_000}}], - "pager": {"totalItems": 1, "totalPages": 1, "page": 0}, + "entities": entities, + "pager": {"totalItems": total, "totalPages": pages, "page": page - 1}, } } } @@ -682,7 +717,7 @@ class _StubbedYandexScraper(YandexRealtyScraper): @pytest.mark.asyncio async def test_yandex_full_load_saves_lot_list_from_real_provider() -> None: - """#3375: save_listings получает СПИСОК лотов, ровно один вызов на бакет.""" + """#3375: save_listings получает СПИСОК лотов из всех трёх веток on_bucket.""" recorder = _RunsRecorder() save_mock = MagicMock(return_value=(1, 0)) enrichment = MagicMock() @@ -702,6 +737,7 @@ async def test_yandex_full_load_saves_lot_list_from_real_provider() -> None: ) assert save_mock.call_count > 0, "save_listings не вызван — прогон ничего не сохранил" + sizes: list[int] = [] for call in save_mock.call_args_list: lots = call.args[1] assert isinstance(lots, list), ( @@ -711,8 +747,22 @@ async def test_yandex_full_load_saves_lot_list_from_real_provider() -> None: assert lots and all(isinstance(lot, ScrapedLot) for lot in lots), ( f"бакет отдал {lots!r} — save_listings пишет не лоты" ) - # По одному новому лоту на бакет (offer_id уникален на ценовой диапазон). - assert counters.unique_fetched == save_mock.call_count > 0 + sizes.append(len(lots)) + # Обе leaf-ветки реально пройдены: одностраничная (1 лот) и многостраничная + # (probe + страницы 2..3). Без этого фальсификация многостраничного вызова + # on_bucket осталась бы зелёной — стаб до неё просто не доходил. + assert _MULTIPAGE_PAGES in sizes, ( + f"ни один бакет не собрал {_MULTIPAGE_PAGES} лота — многостраничный leaf " + f"не пройден, размеры бакетов: {sizes}" + ) + assert 1 in sizes, f"одностраничный leaf не пройден, размеры бакетов: {sizes}" + # То же для _degraded: единственный неполный бакет прогона — тот, чей probe + # провалился (потолком страниц здесь никого не обрезает). + assert counters.partial_buckets == 1, ( + f"partial_buckets={counters.partial_buckets} — degraded-ветка не пройдена " + "(probe-fail не доехал до _degraded)" + ) + assert counters.unique_fetched == sum(sizes) > 0 assert counters.saved_inserted == save_mock.call_count assert _normalize(recorder.calls)[-1][0] == "mark_done"