gendesign/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py
bot-backend 7b36f86ea6
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
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 3m5s
Deploy Trade-In / build-backend (push) Has been cancelled
fix(tradein/domclick): свип ходит через прокси-пул, а отказ узла не обнуляет прогон (#2657) (#2796)
2026-08-09 17:21:49 +00:00

295 lines
13 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.

"""#2670: длинная серия неудач замолкает навсегда; оборванный прогон зовётся успехом.
**1. Анти-спам, запертый в «один раз навсегда».** Оба сторожа слали алерт РОВНО на N-й
подряд неудаче и дальше молчали. Пока источники падали вперемешку с успехами, серия
рвалась и алерт взводился заново; у постоянно сломанного источника рваться нечему.
Прод 2026-08-06:
* `avito_full_load` — 31 неудача подряд (20 failed + 28 banned + 11 cancelled в
истории), последний успешный прогон 03.07, то есть 34 дня без сбора и ровно один
алерт — на третьей неудаче;
* `avito_full_load_exhaustive` — 5 подряд;
* `domclick_city_sweep` — текущий стрик 1 при 47 завершённых прогонах: условие
прерывания у этого сторожа ДОСТИЖИМО (в отличие от #2703, где оно было
недостижимо структурно) — просто у сломанного источника оно не наступает.
Правка: разреженная лестница напоминаний N, 2N, 4N… и не реже, чем раз в
STREAK_ALERT_MAX_PERIOD×N прогонов.
**2. Оборванный прогон.** Признак обрыва рождался в цикле `fetch_city` по ROOM_BUCKETS
(`break` на блоке, `continue` на битом бакете) и наружу не выходил: метод возвращает
голый список лотов, а pipeline писал в `counters.pages_fetched` расчётную оценку
`бакеты × страницы` — не измерение. Поэтому прогон, прошедший 3 бакета из 6, был
неотличим от полного и уходил в `done`. Теперь охват считает сам скрейпер и несёт его
наружу тем же каналом, что и `blocked`.
Фальсификация: на старом коде падают тесты лестницы (сторож молчал при стрике >N) и
тест неполного охвата (прогон с лотами и неполным охватом уходил в `mark_done`).
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.orchestration import runs as kit_runs
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper
from app.services import scrape_runs as app_runs
_MODULES = {"kit": kit_runs, "app": app_runs}
PFX = "scraper_kit.orchestration.pipeline"
def _db(rows: list[Any]) -> MagicMock:
db = MagicMock()
db.execute.return_value.fetchall.return_value = rows
return db
# ── 1. Лестница напоминаний ──────────────────────────────────────────────────
@pytest.mark.parametrize("name", list(_MODULES))
@pytest.mark.parametrize(
("streak", "due"),
[
(0, False),
(2, False),
(3, True), # первый алерт там же, где и раньше
(4, False),
(5, False),
(6, True), # 2N
(9, False),
(12, True), # 4N
(24, True), # 8N
(31, False), # прод-стрик avito_full_load — между вехами
(48, True), # 16N, дальше лестница линейная
(96, True),
(144, True),
(150, False),
],
)
def test_streak_alert_ladder(name: str, streak: int, due: bool) -> None:
"""N, 2N, 4N, 8N… и дальше каждые STREAK_ALERT_MAX_PERIOD×N — не «ровно N»."""
mod = _MODULES[name]
assert mod._streak_alert_due(streak, 3) is due
# ── 2. Сторож неудач: длинная серия не замолкает ─────────────────────────────
def _fail_rows(streak: int, tail: int = 3) -> list[SimpleNamespace]:
"""Свежие сверху: `streak` неудач подряд, затем успешные прогоны."""
return [SimpleNamespace(status="banned") for _ in range(streak)] + [
SimpleNamespace(status="done") for _ in range(tail)
]
def _run_failure_watchdog(mod: Any, rows: list[SimpleNamespace]) -> MagicMock:
sentry = MagicMock()
with patch.object(mod, "sentry_sdk", sentry):
mod._alert_if_consecutive_failures(_db(rows), "avito_full_load")
return sentry
@pytest.mark.parametrize("name", list(_MODULES))
@pytest.mark.parametrize("streak", [3, 6, 12, 24, 48])
def test_failure_watchdog_keeps_reminding(name: str, streak: int) -> None:
"""На старом коде алерт был только при streak == 3; остальные вехи молчали."""
sentry = _run_failure_watchdog(_MODULES[name], _fail_rows(streak))
sentry.capture_message.assert_called_once()
assert f"{streak} consecutive" in sentry.capture_message.call_args[0][0]
@pytest.mark.parametrize("name", list(_MODULES))
@pytest.mark.parametrize("streak", [0, 1, 2, 4, 31])
def test_failure_watchdog_silent_between_milestones(name: str, streak: int) -> None:
"""Анти-спам сохраняется: между вехами сторож молчит."""
sentry = _run_failure_watchdog(_MODULES[name], _fail_rows(streak))
sentry.capture_message.assert_not_called()
@pytest.mark.parametrize("name", list(_MODULES))
def test_failure_streak_is_broken_by_success(name: str) -> None:
"""Успешный прогон рвёт стрик — условие прерывания достижимо (прод: domclick, 1)."""
mod = _MODULES[name]
rows = [
SimpleNamespace(status="banned"),
SimpleNamespace(status="banned"),
SimpleNamespace(status="done"), # рвёт: дальше 10 неудач уже не в счёт
*[SimpleNamespace(status="failed") for _ in range(10)],
]
_run_failure_watchdog(mod, rows).capture_message.assert_not_called()
@pytest.mark.parametrize("name", list(_MODULES))
def test_failure_watchdog_alerts_when_scan_window_is_full(name: str) -> None:
"""Стрик длиннее окна сканирования — молчать нельзя, каким бы ни было число."""
mod = _MODULES[name]
rows = [SimpleNamespace(status="failed") for _ in range(mod.STREAK_SCAN_LIMIT)]
_run_failure_watchdog(mod, rows).capture_message.assert_called_once()
# ── 3. Сторож нулей: та же лестница, семантика #2703 цела ────────────────────
def _zero_rows(streak: int, tail: int = 3) -> list[SimpleNamespace]:
return [SimpleNamespace(status="done", counters={"lots_fetched": 0}) for _ in range(streak)] + [
SimpleNamespace(status="done", counters={"lots_fetched": 42}) for _ in range(tail)
]
def _run_zero_watchdog(mod: Any, rows: list[SimpleNamespace]) -> MagicMock:
sentry = MagicMock()
with patch.object(mod, "sentry_sdk", sentry):
mod._alert_if_consecutive_zero_results(_db(rows), "cian_full_load")
return sentry
@pytest.mark.parametrize("name", list(_MODULES))
@pytest.mark.parametrize(("streak", "called"), [(2, False), (3, True), (6, True), (7, False)])
def test_zero_watchdog_uses_the_same_ladder(name: str, streak: int, called: bool) -> None:
sentry = _run_zero_watchdog(_MODULES[name], _zero_rows(streak))
assert sentry.capture_message.called is called
@pytest.mark.parametrize("name", list(_MODULES))
def test_zero_watchdog_unmeasured_still_breaks_the_streak(name: str) -> None:
"""#2703 не отменяется: «не измерено» рвёт стрик, а не копит его."""
mod = _MODULES[name]
rows = [
SimpleNamespace(status="done", counters={"lots_fetched": 0}),
SimpleNamespace(status="done", counters={"attempted": 5}), # метрики нет
*[SimpleNamespace(status="done", counters={"lots_fetched": 0}) for _ in range(10)],
]
_run_zero_watchdog(mod, rows).capture_message.assert_not_called()
# ── 4. Охват прогона доезжает из скрейпера ───────────────────────────────────
class _FakeFetcher:
async def __aenter__(self) -> _FakeFetcher:
return self
async def __aexit__(self, *args: object) -> None:
return None
def report_ban(self, reason: str) -> None:
return None
@pytest.fixture
def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"scraper_kit.providers._base.build_browser_fetcher",
lambda config, source, **_kw: _FakeFetcher(),
)
async def test_full_sweep_reports_full_coverage(_no_browser: None) -> None:
scraper = DomClickScraper(SimpleNamespace(browser_http_endpoint="http://x:9000"))
with patch.object(DomClickScraper, "_sweep_bucket", AsyncMock(return_value=None)):
await scraper.fetch_city(city_id=4)
assert scraper.buckets_completed == scraper.buckets_total == len(ROOM_BUCKETS)
async def test_broken_bucket_is_not_counted_as_covered(_no_browser: None) -> None:
"""Бакет, упавший на разборе, пропускается (continue) — это и есть обрыв охвата."""
scraper = DomClickScraper(SimpleNamespace(browser_http_endpoint="http://x:9000"))
calls = {"n": 0}
async def _sweep(self: DomClickScraper, **_: object) -> None:
calls["n"] += 1
if calls["n"] in (2, 5):
raise ValueError("bad BFF shape")
with patch.object(DomClickScraper, "_sweep_bucket", _sweep):
await scraper.fetch_city(city_id=4)
assert scraper.buckets_completed == len(ROOM_BUCKETS) - 2
assert scraper.fetch_errors == 2
# ── 5. Неполный охват перестаёт быть «успехом» ───────────────────────────────
class _RunsRecorder:
def __init__(self) -> None:
self.calls: list[str] = []
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:
self.calls.append("update_heartbeat")
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
self.calls.append("mark_done")
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
self.calls.append("mark_failed")
def mark_banned(
self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any
) -> None:
self.calls.append("mark_banned")
async def _drive(*, lots_n: int, done: int, total: int, blocked: bool = False) -> list[str]:
recorder = _RunsRecorder()
lots = [MagicMock() for _ in range(lots_n)]
scraper = MagicMock()
scraper.__aenter__ = AsyncMock(return_value=scraper)
scraper.__aexit__ = AsyncMock(return_value=None)
scraper.fetch_city = AsyncMock(return_value=lots)
scraper.blocked = blocked
scraper.geo_filtered = 0
scraper.fetch_errors = total - done
scraper.buckets_completed = done
scraper.buckets_total = total
with (
patch(f"{PFX}.DomClickScraper", return_value=scraper),
patch(f"{PFX}.save_listings", MagicMock(return_value=(lots_n, 0))),
patch(f"{PFX}.runs", recorder),
):
await run_domclick_city_sweep(
MagicMock(),
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
matcher=MagicMock(),
run_id=1,
city_id=4,
pages=1,
request_delay_sec=0.0,
)
return recorder.calls
async def test_partial_coverage_with_lots_is_not_done() -> None:
"""Прогон, прошедший 3 бакета из 6, не «успешен», даже если лоты есть.
На старом коде эта ветка отсутствовала и прогон уходил в mark_done.
"""
assert (await _drive(lots_n=340, done=3, total=6))[-1] == "mark_failed"
async def test_full_coverage_with_lots_stays_done() -> None:
"""Анти-оверрич: полный охват — по-прежнему done."""
assert (await _drive(lots_n=340, done=6, total=6))[-1] == "mark_done"
async def test_block_still_wins_over_coverage() -> None:
"""Блок проверяется раньше охвата: диагноз «нас прервали снаружи» точнее (#2657)."""
assert (await _drive(lots_n=39, done=2, total=6, blocked=True))[-1] == "mark_banned"
async def test_unknown_coverage_does_not_invent_a_verdict() -> None:
"""Скрейпер не успел создаться (0/0) — судить об охвате нечем, ветка не срабатывает."""
assert (await _drive(lots_n=12, done=0, total=0))[-1] == "mark_done"