gendesign/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
bot-backend 72472c2783
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
fix(tradein/newbuilding): счётчики записи различают вставку и обновление (#2807) (#2809)
2026-08-10 08:50:56 +00:00

200 lines
9.1 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.

"""#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 = "<html>" + "x" * 1_170_000 + "</html>"
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("<html><div class='g-recaptcha'>Вы не робот?</div></html>")
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 = "<html><body>Доступ ограничен</body></html>" # короткая заглушка
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