"""#2674 — detail-backfill с нулём обогащений перестаёт называться 'done'. Все три backfill'а (avito/yandex/domclick) финализировались одним mark_done. На проде 2026-08-06 это 78 прогонов из 158: avito 23/76 (включая 5 прогонов на 1500-1600 попыток без единого обогащения), yandex 31/52, domclick 24/30 (494 попытки → 0 обогащено, 63 блока, 431 fail — и все 30 'done'). Проверяем ровно ветвление mark_backfill_finished — БД замокана (mark_done/mark_failed/ mark_banned здесь fake-заглушки, регистрирующие ТОЛЬКО факт вызова). Это значит: кейсы ниже с высокой долей отказов (attempted=50, failed=38 или 36 — 76%/72%), ожидающие 'done', проверяют лишь то, КАКОЙ финализатор ВЫБРАЛ mark_backfill_finished (#2674: "обогатили хоть что-то — успех"), а НЕ то, что реально запишет в БД mark_done. С honest-run-status (2026-08-15) mark_done САМ переквалифицирует такой прогон в 'failed' через _failed_ratio_too_high (доля отказов >= 0.5) — реальный терминальный статус для этих двух кейсов на проде теперь 'failed', не 'done'. Это намеренно проверяется отдельно, БЕЗ мока mark_done, в tests/test_honest_run_status_failed_ratio.py (test_prod_fact_avito_15_08_no_longer_done и соседние) — не читай эти два кейса как "76%/72% отказов = 'done' в проде". """ from __future__ import annotations import os from typing import Any from unittest.mock import MagicMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services import scrape_runs as runs_mod def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, str | None]: """Вызвать mark_backfill_finished с замоканными mark_* → (статус, причина).""" calls: list[tuple[str, str | None]] = [] with ( patch.object(runs_mod, "mark_done", lambda *a, **k: calls.append(("done", None))), patch.object( runs_mod, "mark_failed", lambda db, rid, err, c: calls.append(("failed", err)) ), # **k — mark_banned принимает ещё и ban_kind (#2686/#2764); этот тест про # ВЫБОР финализатора, диагноз проверяет test_2764_ban_kind_no_default.py. patch.object( runs_mod, "mark_banned", lambda db, rid, err, c, **k: calls.append(("banned", err)) ), ): runs_mod.mark_backfill_finished( MagicMock(), 1, counters, source="domclick_detail_backfill", aborted_by_blocks=aborted, ) assert len(calls) == 1, f"ожидался ровно один финализатор, получено {calls}" return calls[0] @pytest.mark.parametrize( ("counters", "aborted", "expected"), [ # Прод-факт domclick: 16 прогонов attempted=3 blocked=3 → брейкер оборвал. ({"attempted": 3, "enriched": 0, "blocked": 3, "failed": 0}, True, "banned"), # Прод-факт domclick: 100 попыток, все fail, блоки не распознаны (до #2645). ({"attempted": 100, "enriched": 0, "blocked": 0, "failed": 100}, False, "failed"), # Прод-факт avito: 1500 попыток, 1499 fail + 1 блок, ноль обогащений. ({"attempted": 1500, "enriched": 0, "blocked": 1, "failed": 1499}, False, "banned"), # Прод-факт yandex: 31 прогон attempted=5 failed=5, ключа blocked нет вовсе. ({"attempted": 5, "enriched": 0, "failed": 5}, False, "failed"), # Кандидатов не было — честная пустота, это успех. ({"attempted": 0, "enriched": 0, "blocked": 0, "failed": 0}, False, "done"), # Частичный прогон: обогатили хоть что-то → mark_backfill_finished ВЫБИРАЕТ # mark_done как финализатор (#2674). 76% отказов (38 из 50) — здесь mark_done # замокан, поэтому статус остаётся 'done'; в реальном mark_done с # honest-run-status (2026-08-15) это переквалифицируется в 'failed' # (_failed_ratio_too_high, доля >= 0.5) — см. докстринг модуля. ({"attempted": 50, "enriched": 12, "blocked": 0, "failed": 38}, False, "done"), # Блоки были, но прогон доработал и обогатил — mark_backfill_finished выбирает # НЕ 'banned'. 72% отказов (36 из 50) — та же оговорка: реальный mark_done # переквалифицирует в 'failed', см. докстринг модуля выше. ({"attempted": 50, "enriched": 12, "blocked": 2, "failed": 36}, False, "done"), # Блок оборвал прогон, хотя часть успели обогатить — работа не доделана. ({"attempted": 50, "enriched": 12, "blocked": 5, "failed": 33}, True, "banned"), # avito: 404-«снято с продажи» — тоже результат, а не пустой прогон. ({"attempted": 30, "enriched": 0, "gone": 30, "blocked": 0, "failed": 0}, False, "done"), ], ) def test_status_matches_reality(counters: dict[str, Any], aborted: bool, expected: str) -> None: status, _ = _finish(counters, aborted=aborted) assert status == expected def test_reason_carries_numbers_and_marker() -> None: """Причина в scrape_runs.error должна быть читаемой человеком, не пустой.""" status, reason = _finish({"attempted": 3, "enriched": 0, "blocked": 3}, aborted=True) assert status == "banned" assert reason is not None assert "backfill-honest-status" in reason assert "domclick_detail_backfill" in reason assert "blocked=3" in reason and "из 3 попыток" in reason