test(#3375): стаб доходит до _degraded и многостраничного листа; комментарий про красноту
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s

This commit is contained in:
bot-backend 2026-09-06 02:38:12 +05:00
parent e04e315203
commit 235cc3065e

View file

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