"""Честный ban_kind у domclick_detail_backfill по HTTP-статусу ответа (#3196). Проблема (прод, 14 дней): domclick_detail_backfill дал 14 банов и ВСЕ с ban_kind='unknown'. Причин две и обе механические: 1. задача вообще не передавала `ban_kinds` в mark_backfill_finished — диагноз не доезжал до финализатора, каким бы он ни был; 2. DomClickBlockedError поднимается и на распознанном маркере, и на любом сбое браузерного фетча, поэтому по типу исключения их не развести. Фикс: исключение несёт `.status` (HTTP-код целевой навигации, см. BrowserFetcher.last_response_status), задача раскладывает его через ban_kind_from_status и копит перепись kind -> count — ровно как это делает avito_detail_backfill (ban_kind_of_exception → Counter → mark_backfill_finished). 403/429 → 'platform' (замер 28.08: статическая страница `403 | Домклик` на 26 624 байта, без PoW и капчи — отказ, а не челлендж), 5xx → 'infra', статуса нет → 'unknown' (не назначаем причину, которую не установили, #2764). Зеркалит конвенции tests/test_3118_domclick_warm_context.py (module-level patch-target строки, _mock_db/_mock_session_svc helpers). """ from __future__ import annotations import os import sys from collections import Counter from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) import pytest # noqa: E402 from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402 from app.tasks.domclick_detail_backfill import ( # noqa: E402 _ban_kind_of_block, run_domclick_detail_backfill, ) _FETCH = "app.tasks.domclick_detail_backfill.fetch_detail" _SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment" _RUNS = "app.tasks.domclick_detail_backfill.runs_mod" _SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep" _SETTINGS = "app.tasks.domclick_detail_backfill.settings" _BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher" _SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc" def _make_snapshot(n: int) -> list[dict]: return [ { "id": i + 1, "source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}", } for i in range(n) ] def _mock_db(snapshot: list[dict]) -> MagicMock: db = MagicMock() sel = MagicMock() sel.mappings.return_value.all.return_value = snapshot db.execute.return_value = sel return db def _mock_session_svc() -> MagicMock: svc = MagicMock() svc.load_session.return_value = {"CAS_ID": "123"} svc.COOKIE_EXPIRY_WARN_DAYS = 5 svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30) return svc def _mock_browser_fetcher_cls() -> MagicMock: instance = AsyncMock() instance.__aenter__ = AsyncMock(return_value=instance) instance.__aexit__ = AsyncMock(return_value=False) # синхронный метод — иначе остаётся неawaited coroutine instance.request_context_reset = MagicMock() return MagicMock(return_value=instance) async def _run_all_blocked(status: int | None, *, blocks: int = 3) -> MagicMock: """Прогон, где КАЖДЫЙ fetch — блок с данным HTTP-статусом. Возвращает runs-мок.""" snapshot = _make_snapshot(blocks) db = _mock_db(snapshot) runs = MagicMock() mock_fetch = AsyncMock(side_effect=DomClickBlockedError("domclick blocked", status=status)) with ( patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")), patch(_SESSION_SVC, _mock_session_svc()), patch(_RUNS, runs), patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()), patch(_FETCH, mock_fetch), patch(_SAVE, return_value=True), patch(_SLEEP, new_callable=AsyncMock), ): await run_domclick_detail_backfill( db, run_id=3196, params={"batch_size": blocks, "budget_sec": 3600, "max_consecutive_blocks": blocks}, ) return runs def _ban_kinds_of(runs: MagicMock) -> Counter: runs.mark_backfill_finished.assert_called_once() return runs.mark_backfill_finished.call_args.kwargs["ban_kinds"] # ── диагноз одного блока ───────────────────────────────────────────────────── @pytest.mark.parametrize( ("status", "expected"), [ (401, "platform"), # прод 28-29.08: страница отказа байт-в-байт как у 403 (403, "platform"), # ровно тот случай, что намеряли вживую: `403 | Домклик` (429, "platform"), (500, "infra"), (502, "infra"), (503, "infra"), (200, "unknown"), # маркер поймали, но статус про блок ничего не говорит (404, "unknown"), (None, "unknown"), # сайдкар статус не отдал / фетч не состоялся ], ) def test_ban_kind_of_block(status: int | None, expected: str) -> None: assert _ban_kind_of_block(DomClickBlockedError("boom", status=status)) == expected def test_ban_kind_of_block_without_status_attr() -> None: """Исключение без явного статуса → 'unknown', не падение.""" assert _ban_kind_of_block(DomClickBlockedError("legacy")) == "unknown" # ── прогон целиком: диагноз доезжает до финализатора ───────────────────────── @pytest.mark.asyncio async def test_run_403_gets_platform() -> None: """403 (отказ площадки) → ban_kinds={'platform': 3} в mark_backfill_finished.""" runs = await _run_all_blocked(403) assert _ban_kinds_of(runs) == Counter({"platform": 3}) assert runs.mark_backfill_finished.call_args.kwargs["aborted_by_blocks"] is True @pytest.mark.asyncio async def test_run_5xx_gets_infra() -> None: """5xx (сбой площадки/прокси, не адресный бан) → 'infra'.""" runs = await _run_all_blocked(503) assert _ban_kinds_of(runs) == Counter({"infra": 3}) @pytest.mark.asyncio async def test_run_unrecognized_refusal_stays_unknown() -> None: """Статуса нет → по-прежнему 'unknown': причину не выдумываем (#2764).""" runs = await _run_all_blocked(None) assert _ban_kinds_of(runs) == Counter({"unknown": 3}) @pytest.mark.asyncio async def test_run_mixed_statuses_keep_multiplicity() -> None: """Перепись несёт КРАТНОСТИ — их разбирает _dominant_ban_kind, а не set (#3178).""" snapshot = _make_snapshot(3) db = _mock_db(snapshot) runs = MagicMock() mock_fetch = AsyncMock( side_effect=[ DomClickBlockedError("refused", status=403), DomClickBlockedError("refused", status=429), DomClickBlockedError("upstream", status=502), ] ) with ( patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")), patch(_SESSION_SVC, _mock_session_svc()), patch(_RUNS, runs), patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()), patch(_FETCH, mock_fetch), patch(_SAVE, return_value=True), patch(_SLEEP, new_callable=AsyncMock), ): await run_domclick_detail_backfill( db, run_id=3196, params={"batch_size": 3, "budget_sec": 3600, "max_consecutive_blocks": 3}, ) assert _ban_kinds_of(runs) == Counter({"platform": 2, "infra": 1}) @pytest.mark.asyncio async def test_clean_run_passes_empty_ban_kinds() -> None: """Без блоков перепись пуста — финализатор не должен видеть выдуманный диагноз.""" snapshot = _make_snapshot(2) db = _mock_db(snapshot) runs = MagicMock() with ( patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")), patch(_SESSION_SVC, _mock_session_svc()), patch(_RUNS, runs), patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()), patch(_FETCH, AsyncMock(return_value=MagicMock())), patch(_SAVE, return_value=True), patch(_SLEEP, new_callable=AsyncMock), ): await run_domclick_detail_backfill( db, run_id=3196, params={"batch_size": 2, "budget_sec": 3600} ) assert _ban_kinds_of(runs) == Counter()