gendesign/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py
bot-backend 3658434c03
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m41s
fix(tradein/backfill): save-False у yandex/avito больше не теряет попытку — attempted == сумма исходов
Тот же дефект, что #3332 у domclick (#3335), у обоих соседей: `if save(...):
enriched += 1` без else. UPDATE, не задевший строку (объявление удалено между
снимком и записью), оставлял попытку без исхода — attempted переставал сходиться
с суммой исходов, и расхождение читается как потерянный отказ площадки.

Разбор ВСЕХ точек выхода из цикла попыток показал, что у соседей это
единственная дыра: обрыва «пул прокси пуст» у них нет (resolve_proxy_url
бросает ProxyPoolExhaustedError ДО цикла), а budget/SIGTERM-брейки стоят до
`attempted += 1`. Исход — failed с отдельным warning про ненайденную строку.

Формы тождества разные и это не описка: у yandex blocked ⊆ failed (#3196),
у avito blocked/gone/failed — непересекающиеся корзины.

Тесты — по значению (числа, не «не бросило»), плюс контроль на противоположную
ошибку (исход не начисляется дважды). В test_3332 добавлен запрошенный ревью
кейс: транспортный сбой → пустой пул даёт attempted=2 failed=2, а не 3.

Closes #3338
2026-09-05 22:47:08 +05:00

207 lines
9.5 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 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 не должна начислять исход дважды"
)