"""#2767 — сделать причину отказа newbuilding_enrich видимой.
Восемь суток подряд прогон делал 25 попыток, обогащал ноль записей и закрывался
статусом 'done'. Различить «страница пришла целиком, изменилась разметка» и
«пришла защитная заглушка» было нельзя: в логе не было ни размера ответа, ни
признаков защиты, а статус утверждал успех.
Две проверки, обе падают на коде до правки:
1. строка отказа разбора несёт размер полученной страницы и найденные маркеры;
2. прогон с ненулевыми попытками и нулём обогащений финализируется через общий
runs.mark_backfill_finished (#2695), а не через mark_done.
Ни сети, ни БД: BrowserFetcher подменён спаем, финализаторы — заглушками.
"""
from __future__ import annotations
import logging
import os
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
# DATABASE_URL required by config before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint stub — not installed in CI without GTK.
sys.modules.setdefault("weasyprint", MagicMock())
import pytest # noqa: E402
from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
_describe_parse_miss,
fetch_newbuilding,
)
from app.tasks import newbuilding_enrich_backfill as task_mod # noqa: E402
from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402
NewbuildingEnrichBackfillResult,
run_newbuilding_enrich,
)
# ---------------------------------------------------------------------------
# 1. Размер страницы + маркеры защиты в строке отказа разбора.
# ---------------------------------------------------------------------------
def test_describe_parse_miss_reports_size_and_no_markers() -> None:
"""Целая страница без маркеров: размер есть, маркеров нет → «изменилась разметка»."""
html = "" + "x" * 1_170_000 + ""
described = _describe_parse_miss(html)
assert f"html_len={len(html)}" in described
assert "antibot_markers=none" in described
def test_describe_parse_miss_names_found_markers() -> None:
"""Стена капчи: маркер назван, но диагноз не выносится — только перечисление найденного.
Опознаётся по заголовку «Вы не робот?», а НЕ по подстроке "captcha": последняя есть
на любой здоровой карточке ЖК (скрипт SmartCaptcha) и была снята из списка после
того, как забанила здоровые узлы (#2767).
"""
described = _describe_parse_miss("
Вы не робот?
")
assert "вы не робот" in described
assert "html_len=" in described
def _spy_browser_fetcher(html: str) -> MagicMock:
"""Подмена фабрики фетчера: async-CM, чей .fetch() отдаёт заданный html."""
fetcher = MagicMock()
fetcher.fetch = AsyncMock(return_value=html)
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
fetcher.__aexit__ = AsyncMock(return_value=None)
return MagicMock(return_value=fetcher)
@pytest.mark.asyncio
async def test_fetch_newbuilding_logs_page_size_on_parse_miss(monkeypatch, caplog) -> None:
"""Отказ разбора обязан писать РАЗМЕР полученной страницы.
Размер один гипотезы уже НЕ разделяет (страница блока Циана весит 374 КБ — см.
test_2767_cian_waf_block.py), но остаётся обязательным фактом в строке отказа.
"""
shell = "Доступ ограничен" # короткая заглушка
monkeypatch.setattr(
"scraper_kit.providers.cian.newbuilding.build_browser_fetcher",
_spy_browser_fetcher(shell),
)
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"):
result = await fetch_newbuilding(
"https://zhk-tihiy-centr-ekb-i.cian.ru",
config=SimpleNamespace(
browser_http_endpoint="http://tradein-browser:3000",
use_proxy_pool_browser=False,
),
)
assert result is None
message = "\n".join(r.getMessage() for r in caplog.records)
assert "initialState extraction failed" in message
assert f"html_len={len(shell)}" in message
assert "доступ ограничен" in message
# ---------------------------------------------------------------------------
# 2. Честный статус: ноль обогащений при ненулевых попытках ≠ 'done'.
# ---------------------------------------------------------------------------
def _stub_finalisers(monkeypatch: pytest.MonkeyPatch) -> dict:
"""Заглушить lifecycle-функции; вернуть журнал вызовов финализаторов."""
calls: dict = {}
monkeypatch.setattr(task_mod.runs_mod, "update_heartbeat", lambda *a, **k: None)
monkeypatch.setattr(
task_mod.runs_mod,
"mark_done",
lambda _db, run_id, counters: calls.setdefault("mark_done", dict(counters)),
)
monkeypatch.setattr(
task_mod.runs_mod,
"mark_failed",
lambda _db, run_id, err, counters: calls.setdefault("mark_failed", err),
)
monkeypatch.setattr(
task_mod.runs_mod,
"mark_backfill_finished",
lambda _db, run_id, counters, **kw: calls.setdefault(
"finished", {"counters": dict(counters), **kw}
),
)
return calls
def _stub_backfill(
monkeypatch: pytest.MonkeyPatch, result: NewbuildingEnrichBackfillResult
) -> None:
async def _fake(_db, **_kwargs):
return result
monkeypatch.setattr(task_mod, "backfill_newbuilding_enrichment", _fake)
@pytest.mark.asyncio
async def test_zero_enrichment_run_goes_through_shared_finaliser(monkeypatch) -> None:
"""Прод-картина: 25 попыток, 0 успехов, 25 отказов fetch. Финал — общий
mark_backfill_finished (который такой прогон помечает 'failed'), НЕ mark_done."""
_stub_backfill(
monkeypatch,
NewbuildingEnrichBackfillResult(processed=25, succeeded=0, failed_fetch=25),
)
calls = _stub_finalisers(monkeypatch)
await run_newbuilding_enrich(object(), run_id=42, params={}) # type: ignore[arg-type]
assert "mark_done" not in calls # ← падает на старом коде
finished = calls["finished"]
assert finished["source"] == "newbuilding_enrich"
assert finished["counters"]["attempted"] == 25
assert finished["counters"]["enriched"] == 0
assert finished["counters"]["failed"] == 25
# Диагноза «виновата площадка» здесь нет: блоки не считаются вовсе (#2764).
assert finished["counters"].get("blocked") is None
assert "fetch" in finished["fail_hint"]
@pytest.mark.asyncio
async def test_all_skipped_run_reports_zero_attempts(monkeypatch) -> None:
"""Все выбранные дома уже обогащены → attempted=0: честная пустота, а не отказ
(общий финализатор такой прогон закрывает 'done')."""
_stub_backfill(
monkeypatch,
NewbuildingEnrichBackfillResult(processed=25, skipped_already_enriched=25),
)
calls = _stub_finalisers(monkeypatch)
await run_newbuilding_enrich(object(), run_id=43, params={}) # type: ignore[arg-type]
assert calls["finished"]["counters"]["attempted"] == 0
assert calls["finished"]["fail_hint"] is None
@pytest.mark.asyncio
async def test_partial_success_keeps_rich_counters(monkeypatch) -> None:
"""Ключи финализатора ДОБАВЛЯЮТСЯ к своим счётчикам задачи, а не заменяют их."""
_stub_backfill(
monkeypatch,
NewbuildingEnrichBackfillResult(
processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_inserted=7
),
)
calls = _stub_finalisers(monkeypatch)
await run_newbuilding_enrich(object(), run_id=44, params={}) # type: ignore[arg-type]
counters = calls["finished"]["counters"]
assert counters["attempted"] == 10
assert counters["enriched"] == 3
assert counters["failed"] == 7
assert counters["price_dynamics_inserted"] == 7 # исходные счётчики на месте
assert counters["succeeded"] == 3