gendesign/tradein-mvp/backend/tests/test_3191_yandex_detail_completeness.py
bot-backend 78e3da6bc3
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 13s
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 / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m21s
fix(tradein): маркер полноты — encryptedPhones ИЛИ redirectPhones (#3191)
Ревью #3364. Требуя именно encryptedPhones, отбраковывали бы вечно
необмеренный класс карточек «без телефона / только чат»: в очередь они
возвращаются, а ключ не появится. redirectPhones измерен тем же замером
#3192 и присутствует в обеих ветках (с куками и без).

Текст ABORT: consecutive_none смешанный (фетч-ошибка + parse-None +
недогруз) — «N подряд без обогащения», а не «недогруженных».
2026-09-06 00:14:43 +05:00

158 lines
8.2 KiB
Python
Raw 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.

"""Недогруженная карточка Яндекса — отказ, а не успех (#3191).
Наблюдение 2026-08-28 (три карточки, прод-сайдкар, аноним):
offer 52275656 3 920 119 б encryptedPhones 65
offer 62983459 1 805 556 б encryptedPhones 0 <- недогруз
offer 12108408 4 732 884 б encryptedPhones 65
Средняя вернулась вдвое меньше нормы и БЕЗ блока контактов. Не капча и не ошибка:
HTTP 200, HTML валиден, `window.INITIAL_STATE` на месте, парсер отрабатывает.
Единственная проверка размера в провайдере (`newbuilding.py`, `len(html) < 500`)
отвечает на вопрос «пришло ли хоть что-то» — 1,8 МБ проходит её в 3600 раз, и
частичная карточка уезжала в БД с `detail_enriched_at`, выбывая из очереди навсегда.
Проверка ПО ЗНАЧЕНИЮ: сравниваются счётчики и факт вызова записи, а не «не бросило
исключение». Первый кейс — недогруз (карточка НЕ должна сохраниться), второй —
регресс на полной странице (сохраняется как раньше). Тождество attempted (#3338)
проверяется в обоих: incomplete ⊆ failed, как blocked.
"""
from __future__ import annotations
import os
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from app.tasks.yandex_detail_backfill import ( # noqa: E402
YandexDetailBackfillResult,
run_yandex_detail_backfill,
)
_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession"
_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment"
_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"
# Размеры взяты с наблюдения: недогруз 1,8 МБ (порог в 1 МБ проходит — ловит его
# именно отсутствие блока контактов), полная карточка 3,9 МБ. Маркер написан здесь
# литералом НАРОЧНО: тест не должен падать на импорте, когда правку снимают —
# фальсификация обязана краснеть по значению («карточка успешна»), а не по ImportError.
CONTACTS_MARKER = "encryptedPhones"
UNDERRENDERED_HTML = "<html>" + "x" * 1_805_556 + "</html>"
FULL_HTML = "<html>" + "x" * 3_920_119 + f'"{CONTACTS_MARKER}":["a","b"]</html>'
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
def _resp(html: str) -> MagicMock:
resp = MagicMock()
resp.status_code = 200
resp.text = html
return resp
async def _run(html: str) -> tuple[YandexDetailBackfillResult, MagicMock]:
"""Один прогон на одну карточку. parse НАРОЧНО отдаёт разобранную модель:
именно так и было на проде — частичная страница парсится успешно."""
save = MagicMock(return_value=True)
db = _mock_db(1)
session = AsyncMock()
session.get = AsyncMock(side_effect=[_resp(html)])
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=session)
ctx.__aexit__ = AsyncMock(return_value=None)
with (
patch(_ASYNC_SESSION, MagicMock(return_value=ctx)),
patch(_PARSE, return_value=MagicMock()),
patch(_SAVE, save),
patch(_RUNS, MagicMock()),
patch(_SLEEP, new_callable=AsyncMock),
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")),
):
counters = await run_yandex_detail_backfill(
db, run_id=3191, params={"batch_size": 1, "budget_sec": 3600}
)
return counters, save
@pytest.mark.asyncio
async def test_underrendered_page_is_refusal_not_success() -> None:
"""1,8 МБ без блока контактов → incomplete, detail_enriched_at не проставлен."""
counters, save = await _run(UNDERRENDERED_HTML)
# save_detail_enrichment — единственный писатель detail_enriched_at (UPDATE ...
# detail_enriched_at = NOW()). Не вызван = карточка осталась в очереди, её
# возьмёт следующий снапшот (WHERE detail_enriched_at IS NULL).
save.assert_not_called()
assert (counters.enriched, counters.incomplete, counters.failed) == (0, 1, 1), (
f"enriched={counters.enriched} incomplete={counters.incomplete} "
f"failed={counters.failed}: недогруз обязан быть отказом, а не успехом"
)
assert counters.blocked == 0, (
f"blocked={counters.blocked}: площадка ответила HTTP 200 — это не блок"
)
# Тождество #3338: incomplete ⊆ failed, в сумму исходов входит только failed.
assert counters.attempted == counters.enriched + counters.failed == 1
@pytest.mark.asyncio
async def test_full_page_still_enriches() -> None:
"""Регресс: полная карточка сохраняется как раньше."""
counters, save = await _run(FULL_HTML)
save.assert_called_once()
assert (counters.enriched, counters.incomplete, counters.failed) == (1, 0, 0), (
f"enriched={counters.enriched} incomplete={counters.incomplete} "
f"failed={counters.failed}: полная страница должна обогащать как раньше"
)
assert counters.attempted == counters.enriched + counters.failed == 1
def test_reason_names_the_two_signals() -> None:
"""Признака два, и любой из них — отказ."""
from scraper_kit.providers.yandex.detail import detail_incomplete_reason
assert detail_incomplete_reason(FULL_HTML) is None
# Структурный: 1,8 МБ проходит размерный порог, но контактов нет.
assert CONTACTS_MARKER in (detail_incomplete_reason(UNDERRENDERED_HTML) or "")
# Размерный: контакты есть, а страница обрезана.
reason = detail_incomplete_reason(f'"{CONTACTS_MARKER}":[]', min_html_bytes=1_000_000)
assert reason is not None and "недогружена" in reason
def test_redirect_phones_alone_counts_as_complete() -> None:
"""Карточка «без телефона / только чат»: redirectPhones есть, encryptedPhones нет.
Класс в замер #3192 не попадал (там у всех 12 были оба ключа). Требуя именно
encryptedPhones, мы браковали бы такие карточки вечно — они возвращаются в очередь,
а ключ у них не появится. Маркеры литералами: правку снимут — тест обязан краснеть
по значению («карточка отбракована»), а не по ImportError.
"""
from scraper_kit.providers.yandex.detail import detail_incomplete_reason
body = "<html>" + "x" * 3_920_119
assert detail_incomplete_reason(body + '"redirectPhones":["a"]</html>') is None
# Фальсификация: без ОБОИХ ключей та же страница полного размера — недогруз.
assert detail_incomplete_reason(body + "</html>") is not None