gendesign/tradein-mvp/backend/tests/test_3196_yandex_ban_kind.py
bot-backend 4b5f8fb54c
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
fix(tradein): недогруженная карточка Яндекса — отказ, а не успех (#3191)
Страница на 1,8 МБ без блока контактов приходит с HTTP 200 и валидным HTML:
window.INITIAL_STATE на месте, parse отрабатывает — и частичная карточка уезжала
в БД с detail_enriched_at, выбывая из очереди навсегда. Единственная проверка
размера (newbuilding.py, len(html) < 500) отвечала на вопрос «пришло ли хоть
что-то»: 1,8 МБ проходит её в 3600 раз.

Признак полноты структурный + размерный, любой из двух даёт отказ:
encryptedPhones (65 вхождений у полных карточек, 0 у недогруза; отдаётся и
анонимной сессии — см. yandex_session.py) и settings.yandex_detail_min_html_bytes
(1 МБ). Наблюдавшийся недогруз ловит именно структурный: 1,8 МБ порог проходит.

В backfill проверка стоит ДО parse: исход incomplete ⊆ failed, save не
вызывается, значит detail_enriched_at не проставляется и следующий снапшот
(detail_enriched_at IS NULL) возьмёт объявление снова. Серия недогрузов двигает
consecutive_none — тот же брейкер, что у parse→None, поэтому вечно недогружаемая
карточка обрывает прогон, а не молотится (per-listing счётчика попыток в схеме
нет).

Фейковые ответы в тестах-соседях (#3196/#3338) теперь при HTTP 200 выглядят
полной страницей — иначе они молча стали бы кейсами про полноту.
2026-09-05 23:59:15 +05:00

192 lines
8.3 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.

"""#3196: у yandex_detail_backfill не было счётчика blocked — бан был невозможен.
`mark_backfill_finished` переводит прогон в 'banned' только при
`counters["blocked"] > 0`. Яндексовый detail-backfill этот счётчик не вёл вовсе,
поэтому ветка (`scrape_runs.py`, `if blocked and (aborted_by_blocks or produced == 0)`)
была недостижима ПО ПОСТРОЕНИЮ: прод за 14 дней — 12 done, 1 failed, 1 zombie и
НОЛЬ банов, при том что 403/429 от площадки прогоны видели.
Диагноз теперь берётся из HTTP-статуса ответа, а не из текстовых маркеров
страницы (маркеры сняты с чужой площадки и на домкликовском отказе не сработали —
исходный симптом эпика): 403/429 → platform, 5xx → infra, статус, который о блоке
ничего не сообщает → 'unknown' (честное «не знаем», а не «блока не было»).
Тесты гоняют НАСТОЯЩИЙ `mark_backfill_finished` — иначе проверялась бы только
передача аргумента, а не итоговый `scrape_runs.ban_kind`.
"""
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:5432/test")
from app.services import scrape_runs as real_runs
from app.tasks.yandex_detail_backfill import run_yandex_detail_backfill
_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession"
_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
_RUNS = "app.tasks.yandex_detail_backfill.runs_mod"
_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep"
_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url"
class _Recorder:
"""Подменяет runs_mod, но финализацию отдаёт настоящей mark_backfill_finished.
Так тест видит именно тот исход, который получит строка scrape_runs: статус и
ban_kind считает продовый код, а не сам тест.
"""
def __init__(self) -> None:
self.status: str | None = None
self.ban_kind: str | None = None
self.counters: dict[str, Any] = {}
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
return None
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
self.status = "done"
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
self.status = "failed"
def mark_banned(
self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any
) -> None:
self.status = "banned"
self.ban_kind = kw.get("ban_kind")
def mark_backfill_finished(
self, db: Any, run_id: int, counters: dict[str, Any], **kw: Any
) -> None:
self.counters = counters
with (
patch.object(real_runs, "mark_done", self.mark_done),
patch.object(real_runs, "mark_failed", self.mark_failed),
patch.object(real_runs, "mark_banned", self.mark_banned),
):
real_runs.mark_backfill_finished(db, run_id, counters, **kw)
def _mock_db(n: int) -> MagicMock:
snapshot = [
{"id": i + 1, "source_url": f"https://realty.yandex.ru/offer/{i + 1}/"} for i in range(n)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
sel.one.return_value = SimpleNamespace(url_from_offer_id=0, unenrichable_pending=0)
db.execute.return_value = sel
return db
# HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруз теперь отсеивается до
# parse, и страница-заглушка превратила бы эти кейсы в тесты про полноту, а не про
# природу блока. Маркер контактов + размер выше порога — оба признака полноты.
_FULL_PAGE_HTML = "<html>" + "x" * 1_100_000 + '"encryptedPhones":["a"]</html>'
def _resp(status: int) -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.text = _FULL_PAGE_HTML if status == 200 else "<html>отказ</html>"
return resp
def _session_cls(responses: list[MagicMock]) -> MagicMock:
session = AsyncMock()
session.get = AsyncMock(side_effect=responses)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=session)
ctx.__aexit__ = AsyncMock(return_value=None)
return MagicMock(return_value=ctx)
async def _drive(status: int, *, count: int = 5, parse_result: Any = None) -> _Recorder:
"""Прогон из `count` одинаковых ответов со статусом `status`."""
recorder = _Recorder()
db = _mock_db(count)
with (
patch(_ASYNC_SESSION, _session_cls([_resp(status)] * count)),
patch(_PARSE, return_value=parse_result),
patch(_RUNS, recorder),
patch(_SLEEP, new_callable=AsyncMock),
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")),
):
await run_yandex_detail_backfill(
db,
run_id=3196,
params={"batch_size": count, "budget_sec": 3600, "max_consecutive_blocks": count},
)
return recorder
@pytest.mark.asyncio
async def test_http_403_marks_platform_ban() -> None:
"""403 — осознанный отказ площадки. До правки: 0 банов, blocked не считался."""
rec = await _drive(403)
assert rec.status == "banned", rec.status
assert rec.ban_kind == "platform"
assert rec.counters["blocked"] == 5
assert rec.counters["ban_kinds"] == {"platform": 5}
@pytest.mark.asyncio
async def test_http_429_marks_platform_ban() -> None:
"""429 (rate-limit) — та же природа, что 403: отказала площадка."""
rec = await _drive(429)
assert rec.status == "banned"
assert rec.ban_kind == "platform"
@pytest.mark.asyncio
async def test_http_5xx_marks_infra_ban() -> None:
"""5xx — сбой тракта/площадки, не адресный бан: 'infra', не 'platform'."""
rec = await _drive(503)
assert rec.status == "banned"
assert rec.ban_kind == "infra"
assert rec.counters["ban_kinds"] == {"infra": 5}
@pytest.mark.asyncio
async def test_unrecognized_refusal_marks_unknown_ban() -> None:
"""Отказ есть, а статус о его природе молчит → 'unknown', но прогон банится.
Ровно случай эпика: страница-отказ, которую наши маркеры не опознают. Молчание
статуса не должно превращаться в «блока не было».
"""
rec = await _drive(418)
assert rec.status == "banned"
assert rec.ban_kind == "unknown"
assert rec.counters["blocked"] == 5
@pytest.mark.asyncio
async def test_parse_none_at_200_is_not_a_ban() -> None:
"""Контроль: HTTP 200 + parse→None — по-прежнему 'failed', а не 'banned'.
При 200 отличить капчу от промаха НАШЕГО парсера нечем. Считать это блоком
значило бы называть внешней причиной каждый прогон, сломанный у нас, и потерять
сигнал 'failed' из #2674.
"""
rec = await _drive(200, parse_result=None)
assert rec.status == "failed", rec.status
assert rec.ban_kind is None
assert rec.counters["blocked"] == 0
@pytest.mark.asyncio
async def test_successful_run_stays_done() -> None:
"""Контроль: обогащение идёт — прогон 'done', банов нет."""
with patch("app.tasks.yandex_detail_backfill.save_detail_enrichment", return_value=True):
rec = await _drive(200, parse_result=MagicMock())
assert rec.status == "done", rec.status
assert rec.ban_kind is None
assert rec.counters["blocked"] == 0