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
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:
parent
e04e315203
commit
235cc3065e
1 changed files with 61 additions and 11 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue