gendesign/tradein-mvp/backend/tests/test_3118_domclick_checkpoint.py
bot-backend ede5653aba
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-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 4m43s
feat(tradein/domclick): чекпоинты для city_sweep — корзина как единица возобновления (#3118, #3074)
Прод-факты #3118: QRATOR рубит свип внутри 1-2-й корзины при любом старте
(buckets_completed ≤ 1 из 6), banned-прогоны собирают 59–1815 лотов. Сдвиг
#2854 лишь распределяет потери; чекпоинт превращает случайную ротацию в
систематический обход — шесть прогонов закрывают шесть корзин.

Моё раннее «домклику чекпоинтить нечего» (замер в #3074 по средним 3 мин)
опровергнуто данными #3118 — этот коммит исправляет тот вывод кодом.

Зеркально yandex-чекпоинту (#3074): провайдер — skip_buckets + имена
завершённых корзин + гард «цепочка накопила все 6» (честный no-op);
buckets_total сжимается до объёма ЭТОГО прогона; пайплайн — done_buckets =
унаследованное ∪ завершённое, heartbeat с мержем jsonb до финализации;
планировщик — generic _pick_resume (#2845).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:52:44 +05:00

144 lines
5.9 KiB
Python
Raw Permalink 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.

"""#3118/#3074: чекпоинты для domclick_city_sweep — корзина ROOM_BUCKETS как единица.
Прод-факты (#3118): QRATOR обрывает свип внутри 1-2-й корзины при ЛЮБОМ старте
(`buckets_completed ≤ 1` из 6 во всех прогонах), banned-прогоны при этом собирают
591815 лотов. Сдвиг #2854 лишь распределяет потери; чекпоинт превращает
случайную ротацию в систематический обход — шесть прогонов закрывают шесть
корзин. Моё раннее «чекпоинтить нечего» (комментарий в #3074) опровергнуто
данными #3118 — этот файл и есть исправление того вывода кодом.
Три слоя (зеркально yandex #3074): провайдер skip_buckets + имена завершённых;
пайплайн — resume-чтение и done_buckets в counters (мерж jsonb, финализаторы
не затирают); планировщик — generic _pick_resume (#2845) бесплатно.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
from scraper_kit.orchestration import scheduler as sched
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickBlockedError, DomClickScraper
# ── 1. Провайдер: скип, имена, порядок с ротацией #2854 ──────────────────────
async def _no_sleep() -> None:
pass
def _scraper() -> DomClickScraper:
s = DomClickScraper(SimpleNamespace(scraper_proxy_url=None))
s.sleep_between_requests = _no_sleep # type: ignore[method-assign]
return s
async def _run_fetch_city(
scraper: DomClickScraper,
*,
start: int = 0,
skip: set[str] | None = None,
block_on: str | None = None,
) -> list[str]:
"""fetch_city с замоканными сеткой и обходом корзины; возвращает порядок
корзин, по которым РЕАЛЬНО пошёл обход."""
visited: list[str] = []
async def _fake_sweep_bucket(*, rooms: str, **_kw: Any) -> None:
visited.append(rooms)
if block_on is not None and rooms == block_on:
raise DomClickBlockedError("QRATOR")
class _FakeFetcherCtx:
async def __aenter__(self) -> SimpleNamespace:
return SimpleNamespace(report_ban=lambda *_a, **_k: None)
async def __aexit__(self, *_exc: Any) -> None:
return None
with (
patch.object(scraper, "_sweep_bucket", _fake_sweep_bucket),
patch(
"scraper_kit.providers._base.build_browser_fetcher",
lambda *_a, **_k: _FakeFetcherCtx(),
),
):
await scraper.fetch_city(city_id=1, pages=1, start_bucket_index=start, skip_buckets=skip)
return visited
async def test_skip_buckets_are_never_fetched() -> None:
"""Корзины из чекпоинта не получают ни одного обхода; buckets_total честно
сжимается до объёма ЭТОГО прогона (иначе honest-status читал бы
возобновлённый прогон как вечно-частичный)."""
s = _scraper()
visited = await _run_fetch_city(s, start=0, skip={"st", "1", "2"})
assert visited == ["3", "4", "5+"], visited
assert s.buckets_total == 3
assert s.completed_buckets == ["3", "4", "5+"]
async def test_block_midway_records_completed_names() -> None:
"""Блок на 2-й корзине: имена завершённых до блока — источник чекпоинта."""
s = _scraper()
visited = await _run_fetch_city(s, start=1, block_on="2")
# старт со сдвигом 1: порядок 1,2,... — блок на '2' после завершения '1'
assert visited[:2] == ["1", "2"]
assert s.completed_buckets == ["1"], s.completed_buckets
assert s.blocked is True
async def test_all_skipped_is_honest_noop() -> None:
"""Цепочка накопила все 6 корзин → пустой обход без падения (гард)."""
s = _scraper()
visited = await _run_fetch_city(s, skip=set(ROOM_BUCKETS))
assert visited == []
assert s.completed_buckets == []
# ── 2. Планировщик: диспатч отдаёт точку (зеркало test_930/test_3074) ────────
def _candidate() -> SimpleNamespace:
return SimpleNamespace(
prev_id=6001,
prev_status="banned",
prev_counters={"done_buckets": ["st", "1"]},
same_params=True,
age_h=20.0,
interval_days="1",
)
class _FakeDb:
def __init__(self, row: Any) -> None:
self.row = row
def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
if params and "counters" in params:
return MagicMock()
return MagicMock(fetchone=lambda: self.row)
def commit(self) -> None:
pass
async def test_scheduler_hands_checkpoint_to_domclick_sweep() -> None:
"""Красный на main по значению: kwargs без resume_run_id → None != 6001."""
db = _FakeDb(_candidate())
captured: dict[str, Any] = {}
async def _spy(*_a: Any, **kw: Any) -> None:
captured.update(kw)
with patch.object(sched, "run_domclick_city_sweep", _spy):
await sched._job_domclick_city_sweep(db, 7000, {}, MagicMock())
assert captured.get("resume_run_id") == 6001, (
"планировщик не отдал чекпоинт домклик-свипу — прогон пойдёт с нуля"
)