"""#3196: циановский прогон обязан ставить диагноз отказа по HTTP-статусу. До правки браузерный путь `fetch_detail` глотал ЛЮБОЙ отказ: `BrowserFetcher.fetch` возвращает str и на 403 не бросает, парсер не находил состояние и провайдер отдавал None. Батч считал это `listings_failed_fetch`, планировщик звал `mark_done` — прогон уходил в 'done' с пустым `scrape_runs.ban_kind`, и отличить «Циан нас отбил» от «упал наш сайдкар» постфактум было нечем. Теперь отказ читается из `bf.last_response_status` (контракт фундамента #3196): 403/429 → 'platform', 5xx → 'infra', всё прочее → 'unknown'. В 'banned' переводится только прогон, который отказы видел и не обогатил НИЧЕГО, — рабочий путь Циана (13 done против 1 banned за 14 суток) не трогаем. """ from __future__ import annotations import os from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from app.services import scheduler as sched_mod from app.tasks import cian_history_backfill class _FakeBrowserFetcher: """Сайдкар, который отдаёт заданный HTTP-статус последнего ответа.""" def __init__(self, status: int | None = None, **kwargs: Any) -> None: self.last_response_status = status async def __aenter__(self) -> _FakeBrowserFetcher: return self async def __aexit__(self, *_: object) -> None: return None def _fetcher_factory(status: int | None): def _make(**kwargs: Any) -> _FakeBrowserFetcher: return _FakeBrowserFetcher(status) return _make def _db_with_rows(n: int = 1) -> MagicMock: db = MagicMock() db.execute.return_value.mappings.return_value.all.return_value = [ {"id": i, "source_url": f"https://ekb.cian.ru/sale/flat/{i}/"} for i in range(1, n + 1) ] return db async def _run(status: int | None, *, detail: Any, rows: int = 1): """Один прогон listings-блока с заданным статусом и поведением fetch_detail.""" with ( patch.object(cian_history_backfill, "BrowserFetcher", _fetcher_factory(status)), patch.object(cian_history_backfill, "fetch_detail", detail), patch("asyncio.sleep", new_callable=AsyncMock), ): return await cian_history_backfill.backfill_cian_history( _db_with_rows(rows), do_listings=True, do_houses=False, do_valuations=False ) # ── Диагноз по статусу ─────────────────────────────────────────────────────── @pytest.mark.parametrize( ("status", "expected"), [ (403, "platform"), # WAF Циана отбил узел (429, "platform"), # тот же класс: площадка отказала (500, "infra"), # наш сайдкар/прокси не отдал страницу (503, "infra"), # Недиагностируемый ответ отказом НЕ считается (#3196): статуса нет — # сайдкар не дошёл до навигации; HTTP 200 — площадка ответила, а пусто # вернул наш парсер (дрейф разметки). Записав это в ban_kinds, мы бы # объявляли фиктивный бан площадки на собственной поломке. (None, None), (200, None), ], ) async def test_ban_kind_from_http_status(status: int | None, expected: str | None) -> None: result = await _run(status, detail=AsyncMock(return_value=None)) # Попытка провалилась в любом случае — этот счётчик от диагноза не зависит. assert result.listings_failed_fetch == 1 if expected is None: assert result.listings_blocked == 0 assert result.ban_kinds == {} else: assert result.listings_blocked == 1 assert result.ban_kinds == {expected: 1} assert result.ban_kind == expected async def test_fetch_exception_also_carries_diagnosis() -> None: """Отказ, поднятый исключением (CianBlockedError на curl-пути), диагноз тоже несёт.""" result = await _run(403, detail=AsyncMock(side_effect=Exception("HTTP 403 (WAF-блок узла)"))) assert result.listings_blocked == 1 assert result.ban_kind == "platform" async def test_mixed_census_without_majority_is_unknown() -> None: """Ровно поровну platform/infra → честное 'unknown' (правило #3178).""" result = cian_history_backfill.CianBackfillResult() cian_history_backfill._note_refusal(result, 403) cian_history_backfill._note_refusal(result, 503) assert result.ban_kinds == {"platform": 1, "infra": 1} assert result.ban_kind == "unknown" async def test_healthy_run_records_no_refusals() -> None: """Рабочий путь: обогатили — переписи отказов нет, диагноз не навязывается.""" with ( patch.object(cian_history_backfill, "BrowserFetcher", _fetcher_factory(200)), patch.object( cian_history_backfill, "fetch_detail", AsyncMock(return_value=SimpleNamespace(price_changes=[])), ), patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()), patch("asyncio.sleep", new_callable=AsyncMock), ): result = await cian_history_backfill.backfill_cian_history( _db_with_rows(1), do_listings=True, do_houses=False, do_valuations=False ) assert result.listings_succeeded == 1 assert result.listings_blocked == 0 assert result.ban_kinds == {} # ── Диагноз доезжает до строки прогона ─────────────────────────────────────── async def _execute(result: Any) -> SimpleNamespace: fake_runs = SimpleNamespace( update_heartbeat=MagicMock(), mark_done=MagicMock(), mark_banned=MagicMock(), mark_failed=MagicMock(), ) async def _batch(db: Any, **kwargs: Any) -> Any: return result with ( patch.object(sched_mod, "runs_mod", fake_runs), patch.object(cian_history_backfill, "backfill_cian_history", _batch), ): await sched_mod._execute_cian_backfill(MagicMock(), run_id=7, params={}) return fake_runs @pytest.mark.parametrize( ("status", "expected"), [(403, "platform"), (503, "infra")], ) async def test_run_row_gets_ban_kind(status: int | None, expected: str) -> None: """Отказы + ноль обогащений → status='banned' с диагнозом из HTTP-статуса.""" result = cian_history_backfill.CianBackfillResult() result.listings_processed = 3 for _ in range(3): cian_history_backfill._note_refusal(result, status) runs = await _execute(result) assert not runs.mark_done.called, "прогон без единого обогащения не 'done'" assert runs.mark_banned.called assert runs.mark_banned.call_args.kwargs["ban_kind"] == expected assert runs.mark_banned.call_args.args[3]["blocked"] == 3 assert runs.mark_banned.call_args.args[3]["ban_kinds"] == {expected: 3} async def test_parser_drift_is_not_a_ban() -> None: """Ключевой инвариант #3196: HTTP 200 с пустым разбором — НЕ бан площадки. Три карточки подряд отдали 200, а enrichment не собрался — это дрейф разметки Циана либо наш парсер. Прогон обязан остаться нашей поломкой (сюда сходится ветка 'failed' у backfill-ей), а не превратиться в 'banned' с выдуманным диагнозом: именно такие фиктивные 'unknown' и убирает #3196. """ result = cian_history_backfill.CianBackfillResult() result.listings_processed = 3 result.listings_failed_fetch = 3 for _ in range(3): assert cian_history_backfill._note_refusal(result, 200) is None runs = await _execute(result) assert result.ban_kinds == {} assert result.listings_blocked == 0 assert not runs.mark_banned.called, "промах парсера не объявляем отказом площадки" assert runs.mark_done.called async def test_partial_success_stays_done() -> None: """Рабочий путь не трогаем: обогатили хоть что-то — 'done', даже при отказах.""" result = cian_history_backfill.CianBackfillResult() result.listings_processed = 2 result.listings_succeeded = 1 cian_history_backfill._note_refusal(result, 403) runs = await _execute(result) assert runs.mark_done.called assert not runs.mark_banned.called async def test_clean_empty_run_stays_done() -> None: """Кандидатов не было — ни отказов, ни диагноза: 'done' как и раньше.""" runs = await _execute(cian_history_backfill.CianBackfillResult()) assert runs.mark_done.called assert not runs.mark_banned.called