"""#3196: у yandex_detail_backfill не было счётчика blocked — бан был невозможен. `mark_backfill_finished` переводит прогон в 'banned' только при `counters["blocked"] > 0`. Яндексовый detail-backfill этот счётчик не вёл вовсе, поэтому ветка (`scrape_runs.py`, `if blocked and (aborted_by_blocks or produced == 0)`) была недостижима ПО ПОСТРОЕНИЮ: прод за 14 дней — 12 done, 1 failed, 1 zombie и НОЛЬ банов, при том что 403/429 от площадки прогоны видели. Диагноз теперь берётся из HTTP-статуса ответа, а не из текстовых маркеров страницы (маркеры сняты с чужой площадки и на домкликовском отказе не сработали — исходный симптом эпика): 403/429 → platform, 5xx → infra, статус, который о блоке ничего не сообщает → 'unknown' (честное «не знаем», а не «блока не было»). Тесты гоняют НАСТОЯЩИЙ `mark_backfill_finished` — иначе проверялась бы только передача аргумента, а не итоговый `scrape_runs.ban_kind`. """ 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:5432/test") from app.services import scrape_runs as real_runs from app.tasks.yandex_detail_backfill import run_yandex_detail_backfill _ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession" _PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse" _RUNS = "app.tasks.yandex_detail_backfill.runs_mod" _SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep" _RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url" class _Recorder: """Подменяет runs_mod, но финализацию отдаёт настоящей mark_backfill_finished. Так тест видит именно тот исход, который получит строка scrape_runs: статус и ban_kind считает продовый код, а не сам тест. """ def __init__(self) -> None: self.status: str | None = None self.ban_kind: str | None = None self.counters: dict[str, Any] = {} 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: self.status = "done" def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: self.status = "failed" def mark_banned( self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any ) -> None: self.status = "banned" self.ban_kind = kw.get("ban_kind") def mark_backfill_finished( self, db: Any, run_id: int, counters: dict[str, Any], **kw: Any ) -> None: self.counters = counters with ( patch.object(real_runs, "mark_done", self.mark_done), patch.object(real_runs, "mark_failed", self.mark_failed), patch.object(real_runs, "mark_banned", self.mark_banned), ): real_runs.mark_backfill_finished(db, run_id, counters, **kw) def _mock_db(n: int) -> MagicMock: snapshot = [ {"id": i + 1, "source_url": f"https://realty.yandex.ru/offer/{i + 1}/"} for i in range(n) ] db = MagicMock() sel = MagicMock() sel.mappings.return_value.all.return_value = snapshot sel.one.return_value = SimpleNamespace(url_from_offer_id=0, unenrichable_pending=0) db.execute.return_value = sel return db # HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруз теперь отсеивается до # parse, и страница-заглушка превратила бы эти кейсы в тесты про полноту, а не про # природу блока. Маркер контактов + размер выше порога — оба признака полноты. _FULL_PAGE_HTML = "" + "x" * 1_100_000 + '"encryptedPhones":["a"]' def _resp(status: int) -> MagicMock: resp = MagicMock() resp.status_code = status resp.text = _FULL_PAGE_HTML if status == 200 else "отказ" return resp def _session_cls(responses: list[MagicMock]) -> MagicMock: session = AsyncMock() session.get = AsyncMock(side_effect=responses) ctx = MagicMock() ctx.__aenter__ = AsyncMock(return_value=session) ctx.__aexit__ = AsyncMock(return_value=None) return MagicMock(return_value=ctx) async def _drive(status: int, *, count: int = 5, parse_result: Any = None) -> _Recorder: """Прогон из `count` одинаковых ответов со статусом `status`.""" recorder = _Recorder() db = _mock_db(count) with ( patch(_ASYNC_SESSION, _session_cls([_resp(status)] * count)), patch(_PARSE, return_value=parse_result), patch(_RUNS, recorder), patch(_SLEEP, new_callable=AsyncMock), patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")), ): await run_yandex_detail_backfill( db, run_id=3196, params={"batch_size": count, "budget_sec": 3600, "max_consecutive_blocks": count}, ) return recorder @pytest.mark.asyncio async def test_http_403_marks_platform_ban() -> None: """403 — осознанный отказ площадки. До правки: 0 банов, blocked не считался.""" rec = await _drive(403) assert rec.status == "banned", rec.status assert rec.ban_kind == "platform" assert rec.counters["blocked"] == 5 assert rec.counters["ban_kinds"] == {"platform": 5} @pytest.mark.asyncio async def test_http_429_marks_platform_ban() -> None: """429 (rate-limit) — та же природа, что 403: отказала площадка.""" rec = await _drive(429) assert rec.status == "banned" assert rec.ban_kind == "platform" @pytest.mark.asyncio async def test_http_5xx_marks_infra_ban() -> None: """5xx — сбой тракта/площадки, не адресный бан: 'infra', не 'platform'.""" rec = await _drive(503) assert rec.status == "banned" assert rec.ban_kind == "infra" assert rec.counters["ban_kinds"] == {"infra": 5} @pytest.mark.asyncio async def test_unrecognized_refusal_marks_unknown_ban() -> None: """Отказ есть, а статус о его природе молчит → 'unknown', но прогон банится. Ровно случай эпика: страница-отказ, которую наши маркеры не опознают. Молчание статуса не должно превращаться в «блока не было». """ rec = await _drive(418) assert rec.status == "banned" assert rec.ban_kind == "unknown" assert rec.counters["blocked"] == 5 @pytest.mark.asyncio async def test_parse_none_at_200_is_not_a_ban() -> None: """Контроль: HTTP 200 + parse→None — по-прежнему 'failed', а не 'banned'. При 200 отличить капчу от промаха НАШЕГО парсера нечем. Считать это блоком значило бы называть внешней причиной каждый прогон, сломанный у нас, и потерять сигнал 'failed' из #2674. """ rec = await _drive(200, parse_result=None) assert rec.status == "failed", rec.status assert rec.ban_kind is None assert rec.counters["blocked"] == 0 @pytest.mark.asyncio async def test_successful_run_stays_done() -> None: """Контроль: обогащение идёт — прогон 'done', банов нет.""" with patch("app.tasks.yandex_detail_backfill.save_detail_enrichment", return_value=True): rec = await _drive(200, parse_result=MagicMock()) assert rec.status == "done", rec.status assert rec.ban_kind is None assert rec.counters["blocked"] == 0