gendesign/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py
bot-backend e2e7e17f95
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m15s
fix(domclick): каждая попытка добора получает исход (#3332)
Обрыв «пул прокси пуст» уходил из цикла между attempted++ и записью исхода,
поэтому тождество attempted = enriched + failed + blocked ломалось ровно на 1
(прод: 5 прогонов с diff=1). Исход честно failed, не blocked: к площадке не
ходили, отказала наша инфраструктура — тот же разряд, что у транспортных сбоев
(#3283); причина прогона по-прежнему в no_proxy_stop=1 + mark_failed.

Та же дыра закрыта у save_detail_enrichment(...) is False: карточка разобрана,
но строки уже нет — попытка была, исхода не было.

Closes #3332
2026-09-02 17:35:48 +05:00

179 lines
8 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.

"""Тождество счётчиков 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 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
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_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 не должна начислять исход дважды"
)