"""#2625: прогон, у которого отказом кончился каждый якорь, перестаёт быть 'done'. Задача заводилась про капчу Циана и пустые выдачи Яндекса. Основную её часть закрыл #2642 (детект провала извлечения структуры → 'banned'). Замер на проде 2026-08-10 подтверждает эффект симптоматически: развёртки cian/yandex, статус 'done' с нулём — 42 из 89 до деплоя #2642 (04.08 19:00 UTC) и 1 из 30 после. Этот единственный оставшийся — прогон 3557 (`yandex_city_sweep_pervouralsk`, 09.08 17:11): 155 миллисекунд, `{"anchors_total": 1, "anchors_done": 1, "errors_count": 1, "lots_fetched": 0}`, статус 'done'. И он не новый: за 90 суток под тот же признак попадают 28 прогонов, включая `yandex_city_sweep_nizhniy_tagil` — 16 суток подряд (15-30.07), каждый ровно 240 секунд (таймаут якоря), ноль лотов, 'done' каждый раз. Почему детект #2642 их не видит — и правильно не видит. Он считает попытки РАЗБОРА (`_track_gate_result`), а transport_error туда НАМЕРЕННО не попадает (#2600 п.1: «наш прокси сдох» не должен выглядеть баном площадки). Якорь, упавший по таймауту или исключению до первого разобранного ответа, даёт `attempts == 0`, условие `attempts > 0 and failures == attempts` молчит — и прогон уходит в 'done'. То есть третий исход существовал, но терминального статуса у него не было: * площадка отбила → 'banned' (#2642, есть); * площадка честно пуста → 'done' (есть, и это здоровый ответ); * мы не дошли → ??? → 'done' ← дефект. Признак намеренно НЕ опирается на список антибот-маркеров: маркеры объясняют уже случившийся отказ и молчат про неизвестный (урок 09.08, #2798). Здесь считается собственная бухгалтерия прогона — `errors_count >= anchors_total` при измеренном нуле. Что она доказывает: каждый якорь кончился отказом и собрано ноль. Чего НЕ доказывает: кто виноват. Поэтому 'failed' без ban_kind, а не 'banned'/'platform'. """ 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 scraper_kit.orchestration import runs as kit_runs from app.services import scrape_runs as app_runs _MODULES = {"kit": kit_runs, "app": app_runs} # Реальные counters с прода (2026-08-10), не выдуманные. PROD_3557_PERVOURALSK = { "anchors_done": 1, "errors_count": 1, "lots_fetched": 0, "lots_updated": 0, "anchors_total": 1, "lots_inserted": 0, "address_failed": 0, "combos_skipped": 0, "address_enriched": 0, "address_attempted": 0, "price_history_rows": 0, } # Тот же source, прогон 3320 от 06.08 — отказ якоря БЫЛ (errors_count=1), но 117 лотов # собрано. Частичный сбор — сбор, статус обязан остаться 'done'. PROD_3320_PARTIAL = {**PROD_3557_PERVOURALSK, "lots_fetched": 117, "lots_inserted": 63} # Прогон 2930 (cian_city_sweep_serov, 02.08): ноль лотов БЕЗ единого отказа — # это либо честная пустота, либо недетект капчи; и то и другое — не наша тема, # капча — предмет #2642, а честная пустота обязана оставаться 'done'. PROD_2930_HONEST_EMPTY = {**PROD_3557_PERVOURALSK, "errors_count": 0} def _capture_status(mod: Any, counters: dict[str, int]) -> list[str]: """Прогнать mark_done на фейковой сессии, вернуть статусы всех UPDATE'ов. Читаем СТАТУС В SQL, а не имя вызванной функции: тест должен краснеть на поведении финализатора, а не на отсутствии нового имени в старом коде. """ statuses: list[str] = [] def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: sql = str(stmt) for status in ("done", "failed", "banned"): if f"status = '{status}'" in sql: statuses.append(status) return MagicMock() db = MagicMock() db.execute.side_effect = _execute # sentry заглушаем: алерт-хуки best-effort и к предмету теста отношения не имеют. with patch.object(mod, "sentry_sdk", MagicMock()): mod.mark_done(db, 3557, dict(counters)) return statuses # ── 1. Три исхода разведены ────────────────────────────────────────────────── @pytest.mark.parametrize("name", list(_MODULES)) def test_all_anchors_failed_zero_lots_is_not_done(name: str) -> None: """Прод-прогон 3557: все якоря отказали, собрано 0 → 'failed', НЕ 'done'. Красный на старом коде: mark_done писал status='done'. """ assert _capture_status(_MODULES[name], PROD_3557_PERVOURALSK) == ["failed"] @pytest.mark.parametrize("name", list(_MODULES)) def test_partial_harvest_stays_done(name: str) -> None: """Прод-прогон 3320: отказ якоря был, но 117 лотов собрано → остаётся 'done'.""" assert _capture_status(_MODULES[name], PROD_3320_PARTIAL) == ["done"] @pytest.mark.parametrize("name", list(_MODULES)) def test_honest_empty_stays_done(name: str) -> None: """Ноль лотов без единого отказа — честная пустота, 'done' (ложной тревоги нет).""" assert _capture_status(_MODULES[name], PROD_2930_HONEST_EMPTY) == ["done"] @pytest.mark.parametrize("name", list(_MODULES)) def test_failed_run_carries_no_ban_diagnosis(name: str) -> None: """Причина не установлена → ban_kind не пишется вовсе (#2764). 'banned' с диагнозом означал бы «нас забанила площадка» — а мы знаем только, что якоря отказали. Ротацию IP (#2611) на догадке дёргать нельзя. """ mod = _MODULES[name] sqls: list[str] = [] def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: sqls.append(str(stmt)) return MagicMock() db = MagicMock() db.execute.side_effect = _execute with patch.object(mod, "sentry_sdk", MagicMock()): mod.mark_done(db, 3557, dict(PROD_3557_PERVOURALSK)) assert not any("ban_kind" in s for s in sqls), "статус не должен нести диагноз" assert any("status = 'failed'" in s for s in sqls) # ── 2. Классификатор: границы, на которых легко получить ложную тревогу ────── @pytest.mark.parametrize("name", list(_MODULES)) @pytest.mark.parametrize( ("counters", "flagged", "why"), [ ({"anchors_total": 1, "errors_count": 1, "lots_fetched": 0}, True, "1 из 1 отказал"), ({"anchors_total": 5, "errors_count": 5, "lots_fetched": 0}, True, "5 из 5 (avito ЕКБ)"), ({"anchors_total": 5, "errors_count": 1, "lots_fetched": 0}, False, "1 из 5 — не все"), ({"anchors_total": 5, "errors_count": 5, "lots_fetched": 12}, False, "собрано 12"), ({"anchors_total": 1, "errors_count": 0, "lots_fetched": 0}, False, "честная пустота"), # full-load'ы пишут unique_fetched, а не lots_fetched — тот же смысл. ({"anchors_total": 2, "errors_count": 2, "unique_fetched": 0}, True, "full-load ноль"), ({"anchors_total": 2, "errors_count": 2, "unique_fetched": 340}, False, "full-load сбор"), # Результат НЕ измерен — судить нечем, ноль не выдумывается (#2703). ({"anchors_total": 1, "errors_count": 1}, False, "результата в counters нет"), # Якорей нет вовсе: это не развёртка, чужой словарь счётчиков не трогаем. ({"errors_count": 9, "lots_fetched": 0}, False, "не развёртка"), ({"attempted": 5, "enriched": 0, "failed": 5}, False, "detail-backfill, чужой словарь"), ({}, False, "пустые counters"), ({"anchors_total": 0, "errors_count": 0, "lots_fetched": 0}, False, "нуль якорей"), ], ) def test_classifier_boundaries( name: str, counters: dict[str, Any], flagged: bool, why: str ) -> None: reason = _MODULES[name]._sweep_run_did_nothing(counters) assert (reason is not None) is flagged, why if flagged: assert "#2625" in (reason or "") # ── 3. Ровно те 16 суток, что прод прожил молча ────────────────────────────── @pytest.mark.parametrize("name", list(_MODULES)) def test_sixteen_silent_tagil_runs_would_have_been_failed(name: str) -> None: """15-30.07: 16 прогонов `yandex_city_sweep_nizhniy_tagil` по 240 с, 0 лотов. Каждый отчитался 'done'. Ни один алерт их не поднял: `_alert_if_consecutive_failures` считает только failed/banned. С этой правкой все 16 — 'failed', то есть первый же из них попадает в лестницу вех failed-стрика (#2670). """ tagil = {"anchors_total": 1, "anchors_done": 1, "errors_count": 1, "lots_fetched": 0} statuses = [_capture_status(_MODULES[name], tagil) for _ in range(16)] assert statuses == [["failed"]] * 16