"""Тождество счётчиков domclick_detail_backfill: attempted == сумма исходов (#3332). `counters.attempted` инкрементируется ДО попытки, исход дописывается уже в разборе результата. Обрыв «пул прокси пуст» (`_caused_by_empty_pool` → `no_proxy_stop = True; break`) уходил из цикла между этими двумя точками, поэтому текущая попытка оставалась без исхода: прод дал 5 прогонов с `attempted - (enriched + failed + blocked) == 1`. Расхождение читается как потерянный отказ площадки, хотя площадку не трогали вовсе. Исход выбран `failed`, а не `blocked`: к площадке не ходили, отказала наша инфраструктура — тот же разряд, что у транспортных сбоев (`_is_transport_failure`, #3283). Причину прогона несёт `no_proxy_stop=1` + `mark_failed`, счётчик её не подменяет. Вторая дыра того же класса (закрыта здесь же): `save_detail_enrichment` вернул False — карточка разобрана, но строки уже нет; попытка была, исхода не было. Проверка ПО ЗНАЧЕНИЮ: сравниваются числа, а не «не бросило исключение». Харнесс зеркалит tests/test_3196_domclick_ban_kind.py. """ from __future__ import annotations import os import sys 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 httpx # noqa: E402 import pytest # noqa: E402 from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402 from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402 from app.tasks.domclick_detail_backfill import ( # noqa: E402 DomClickDetailBackfillResult, 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) instance.request_context_reset = MagicMock() return MagicMock(return_value=instance) def _empty_pool_block() -> DomClickBlockedError: """Как это приходит с прода: fetch_detail заворачивает ЛЮБОЙ сбой фетча в DomClickBlockedError через `raise ... from exc`, поэтому пустой пул лежит в __cause__ и снаружи выглядит блокировкой.""" blocked = DomClickBlockedError("browser fetch failed") blocked.__cause__ = NoProxyAvailableError("domclick") return blocked def _transport_block() -> DomClickBlockedError: """Сбой НАШЕЙ стороны (таймаут/5xx сайдкара): httpx-ошибка в __cause__ — ровно то, что читает `_is_transport_failure` (#3283).""" blocked = DomClickBlockedError("browser fetch failed") blocked.__cause__ = httpx.ConnectTimeout("sidecar timed out") return blocked async def _run( fetch: AsyncMock, *, snapshot: int, save_ok: bool = True ) -> tuple[DomClickDetailBackfillResult, MagicMock]: db = _mock_db(_make_snapshot(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, fetch), patch(_SAVE, return_value=save_ok), patch(_SLEEP, new_callable=AsyncMock), ): counters = await run_domclick_detail_backfill( db, run_id=3332, params={"batch_size": snapshot, "budget_sec": 3600, "max_consecutive_blocks": 5}, ) return counters, runs def _assert_identity(counters: DomClickDetailBackfillResult, *, expected_attempted: int) -> None: outcomes = counters.enriched + counters.failed + counters.blocked assert counters.attempted == expected_attempted, ( f"attempted={counters.attempted}, ожидали {expected_attempted} попыток" ) assert counters.attempted == outcomes, ( f"тождество нарушено: attempted={counters.attempted}, " f"сумма исходов={outcomes} (enriched={counters.enriched} " f"failed={counters.failed} blocked={counters.blocked}), " f"потеряно {counters.attempted - outcomes} попыток без исхода" ) @pytest.mark.asyncio async def test_empty_pool_mid_run_keeps_identity() -> None: """2 успеха, на 3-й попытке пул пуст → attempted=3 == 2 enriched + 1 failed.""" fetch = AsyncMock(side_effect=[MagicMock(), MagicMock(), _empty_pool_block()]) counters, runs = await _run(fetch, snapshot=5) _assert_identity(counters, expected_attempted=3) assert counters.failed == 1, ( f"failed={counters.failed}: оборванная пустым пулом попытка должна быть " "засчитана отказом НАШЕЙ стороны" ) assert counters.blocked == 0, ( f"blocked={counters.blocked}: площадка не отказывала, запроса к ней не было — " "начислять блок значит лгать про причину (#3283)" ) # Причина обрыва по-прежнему в записи прогона, а не подменена счётчиком. runs.mark_failed.assert_called_once() assert runs.mark_failed.call_args.args[3]["no_proxy_stop"] == 1 @pytest.mark.asyncio async def test_missing_row_on_save_keeps_identity() -> None: """save_detail_enrichment вернул False (строки уже нет) → попытка не теряется.""" fetch = AsyncMock(return_value=MagicMock()) counters, _ = await _run(fetch, snapshot=2, save_ok=False) _assert_identity(counters, expected_attempted=2) assert counters.enriched == 0 and counters.failed == 2, ( f"enriched={counters.enriched} failed={counters.failed}: " "непрошедший UPDATE — не успех и не блок" ) @pytest.mark.asyncio async def test_transport_failure_then_empty_pool_counts_each_once() -> None: """Контроль двойного начисления на самом пути пула (#3338, просьба ревью). Транспортный сбой уже начисляет failed и идёт `continue`; следующая попытка упирается в пустой пул и начисляет failed повторно — но СВОЙ, за СВОЮ попытку. attempted=2 и failed=2, а не 3: правка #3332 не должна начислять исход второй раз за ту же попытку. """ fetch = AsyncMock(side_effect=[_transport_block(), _empty_pool_block()]) counters, _ = await _run(fetch, snapshot=5) _assert_identity(counters, expected_attempted=2) assert (counters.failed, counters.blocked, counters.enriched) == (2, 0, 0), ( f"failed={counters.failed} blocked={counters.blocked} enriched={counters.enriched}, " "ожидали 2/0/0 — по одному отказу нашей стороны на каждую из двух попыток" ) @pytest.mark.asyncio async def test_blocked_and_enriched_counted_once() -> None: """Контроль на противоположную ошибку: блоки/успехи по-прежнему по одному разу.""" fetch = AsyncMock( side_effect=[ MagicMock(), DomClickBlockedError("refused", status=403), MagicMock(), ] ) counters, _ = await _run(fetch, snapshot=3) _assert_identity(counters, expected_attempted=3) assert (counters.enriched, counters.blocked, counters.failed) == (2, 1, 0), ( f"enriched={counters.enriched} blocked={counters.blocked} failed={counters.failed}, " "ожидали 2/1/0 — правка #3332 не должна начислять исход дважды" )