gendesign/tradein-mvp/backend/tests/test_domclick_incremental_save.py
lekss361 baed8f75a3
All checks were successful
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m55s
Deploy Trade-In / build-backend (push) Successful in 1m47s
Deploy Trade-In / deploy (push) Successful in 3m28s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
Свип ДомКлика ходит в тёплом браузерном контексте, а не поднимает камуфокс на каждый фетч (#3595)
2026-09-17 18:10:25 +00:00

234 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""fix/domclick-incremental-save: большая выдача (Москва ≈23 690 лотов против ЕКБ
≈6 300) теряла ВСЁ собранное при снятии свипа по watchdog — сохранение было ОДНО,
в самом конце `run_domclick_city_sweep`. Прод run 7344 (`domclick_city_sweep_moskva`):
за 2ч watchdog'а (11 100с) пройдено 2 бакета из 6.
Три дефекта, три слоя тестов ниже:
1. Инкрементальное сохранение по бакетам (`fetch_city.on_bucket`) — секция 1.
2. Чекпоинт врал ("пройдено" без "сохранено") — секция 1, тест
`test_on_bucket_exception_interrupts_bucket_loop` документирует нюанс, на
котором строится фикс в pipeline.py: scraper.completed_buckets отмечает бакет
как "фетч прошёл" ДО вызова on_bucket, поэтому пайплайн больше не берёт
чекпоинт оттуда — только из факта успешного on_bucket (см. комментарий у
"Перенести счётчики" в run_domclick_city_sweep).
3. Watchdog не знал о размере выдачи — секция 2, `watchdog_sec` override.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
import pytest
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
from scraper_kit.providers.domclick.serp import (
ROOM_BUCKETS,
DomClickBlockedError,
DomClickScraper,
)
PFX = "scraper_kit.orchestration.pipeline"
# ── Секция 1: DomClickScraper.fetch_city(on_bucket=...) ──────────────────────
def _scraper() -> DomClickScraper:
return DomClickScraper(SimpleNamespace(scraper_proxy_url=None))
class _FakeFetcherCtx:
async def __aenter__(self) -> SimpleNamespace:
# request_context_reset (#3118): вызывается ДО report_ban на QRATOR-блоке —
# двойник фетчера обязан его иметь, иначе AttributeError на первом же блоке.
return SimpleNamespace(
report_ban=lambda *_a, **_k: None,
request_context_reset=lambda *_a, **_k: None,
)
async def __aexit__(self, *_exc: Any) -> None:
return None
async def _run_fetch_city(
scraper: DomClickScraper,
*,
sweep_bucket: Any,
on_bucket: Any = None,
start: int = 0,
skip: set[str] | None = None,
) -> list[str]:
with (
patch.object(scraper, "_sweep_bucket", sweep_bucket),
patch(
"scraper_kit.providers._base.build_browser_fetcher",
lambda *_a, **_k: _FakeFetcherCtx(),
),
):
return await scraper.fetch_city(
city_id=1,
pages=1,
start_bucket_index=start,
skip_buckets=skip,
on_bucket=on_bucket,
)
async def test_on_bucket_called_once_per_bucket_with_only_its_own_lots() -> None:
"""on_bucket зовётся по разу на каждый успешный бакет с лотами ИМЕННО его,
а не накопленным out_lots (главная регрессия #1 issue — было "одно сохранение
в конце")."""
async def _fake_sweep(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
out_lots.append(f"{rooms}-a")
out_lots.append(f"{rooms}-b")
calls: list[tuple[str, list[str]]] = []
s = _scraper()
result = await _run_fetch_city(
s, sweep_bucket=_fake_sweep, on_bucket=lambda n, lots: calls.append((n, list(lots)))
)
assert [c[0] for c in calls] == list(ROOM_BUCKETS)
for bucket, lots in calls:
assert lots == [f"{bucket}-a", f"{bucket}-b"], (bucket, lots, "получил чужие лоты")
assert len(result) == len(ROOM_BUCKETS) * 2
async def test_on_bucket_skips_failed_and_blocked_buckets() -> None:
"""Битый бакет (generic Exception) и бакет с QRATOR-блоком не отдают лоты в
on_bucket — там ничего не собрано/не гарантированно собрано."""
async def _fake_sweep_fail_middle(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
out_lots.append(f"{rooms}-x")
if rooms == "1":
raise ValueError("boom")
calls_a: list[str] = []
s_a = _scraper()
await _run_fetch_city(
s_a, sweep_bucket=_fake_sweep_fail_middle, on_bucket=lambda n, _lots: calls_a.append(n)
)
assert "1" not in calls_a
# continue идёт дальше — остальные бакеты всё равно получают on_bucket.
assert calls_a == [b for b in ROOM_BUCKETS if b != "1"], calls_a
async def _fake_sweep_block(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
out_lots.append(f"{rooms}-x")
if rooms == "1":
raise DomClickBlockedError("QRATOR")
calls_b: list[str] = []
s_b = _scraper()
await _run_fetch_city(
s_b, sweep_bucket=_fake_sweep_block, on_bucket=lambda n, _lots: calls_b.append(n)
)
# break останавливает обход целиком — после блока ни один бакет не пробуется.
assert calls_b == ["st"], calls_b
async def test_on_bucket_exception_interrupts_bucket_loop() -> None:
"""Исключение из on_bucket (канал кооперативной отмены) прерывает цикл по
ROOM_BUCKETS целиком — остальные бакеты не идут."""
async def _fake_sweep(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
out_lots.append(f"{rooms}-x")
visited: list[str] = []
def _on_bucket(name: str, lots: list[str]) -> None:
visited.append(name)
if name == "1":
raise RuntimeError("cancelled")
s = _scraper()
with pytest.raises(RuntimeError, match="cancelled"):
await _run_fetch_city(s, sweep_bucket=_fake_sweep, on_bucket=_on_bucket)
assert visited == ["st", "1"], visited
# Скрейпер-уровень успел зафетчить оба бакета ДО того, как on_bucket поднял
# исключение — на этом нюансе строится фикс чекпоинта в pipeline.py: pipeline
# больше не берёт done_buckets из scraper.completed_buckets, только из факта
# успешного on_bucket (см. run_domclick_city_sweep._on_bucket/_checkpoint).
assert s.completed_buckets == ["st", "1"], s.completed_buckets
async def test_on_bucket_none_preserves_return_value() -> None:
"""on_bucket=None → поведение прежнее, байт-в-байт: лоты возвращаются из
fetch_city как раньше, никаких промежуточных вызовов."""
async def _fake_sweep(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
out_lots.append(f"{rooms}-only")
s = _scraper()
result = await _run_fetch_city(s, sweep_bucket=_fake_sweep, on_bucket=None)
assert result == [f"{b}-only" for b in ROOM_BUCKETS]
assert s.completed_buckets == list(ROOM_BUCKETS)
# ── Секция 2: run_domclick_city_sweep(watchdog_sec=...) ──────────────────────
class _NoOpRuns:
"""Достаточно методов, чтобы SERP-фаза дошла до asyncio.wait_for и честно
финализировалась после симулированного TimeoutError — значения не важны,
важен ТОЛЬКО timeout, с которым позвали wait_for."""
def is_cancelled(self, db: Any, run_id: int) -> bool:
return False
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
return None
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
return None
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
return None
def mark_banned(
self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any
) -> None:
return None
async def _drive_and_capture_timeout(**kwargs: Any) -> float:
captured: dict[str, float] = {}
async def _fake_wait_for(coro: Any, timeout: float) -> None:
captured["timeout"] = timeout
coro.close()
raise TimeoutError()
with (
patch(f"{PFX}.asyncio.wait_for", _fake_wait_for),
patch(f"{PFX}.runs", _NoOpRuns()),
):
await run_domclick_city_sweep(
object(), # type: ignore[arg-type]
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
matcher=object(),
run_id=1,
city_id=4,
**kwargs,
)
return captured["timeout"]
async def test_watchdog_sec_default_formula_matches_prod_11100() -> None:
"""watchdog_sec=None (дефолт) → прежняя формула байт-в-байт. pages=100,
delay=6.0 — те же параметры, что дали 11 100с в run 7344 (Москва)."""
timeout = await _drive_and_capture_timeout(pages=100, request_delay_sec=6.0)
assert timeout == 11100
async def test_watchdog_sec_override_bypasses_formula() -> None:
"""watchdog_sec задан явно → формула не считается вовсе, идёт ровно override
— даже с теми же pages/delay, что в тесте формулы выше."""
timeout = await _drive_and_capture_timeout(pages=100, request_delay_sec=6.0, watchdog_sec=777)
assert timeout == 777