gendesign/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py
bot-backend 5baf07d6e6
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 / browser-tests (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 4m53s
fix(tradein/domclick): свип брал BFF навигацией браузера — теперь подзапросом (#3264)
BFF-ручка Домклика не страница, а JSON-эндпоинт SPA. Сайдкар умел ровно
одно — page.goto(url), — и навигацией браузера на API-хост мы делали то,
чего настоящий клиент не делает никогда. Перехват сети на живой выдаче
30.08 это и показал: офферы приезжают в SSR-документе, а к BFF ходят
XHR'ы за гео, районами и метро.

Через мобильные узлы пула такая навигация упиралась в ChallengeTimeout:
прогоны 5330 и 5351 — 9 из 9 и 6 из 6 запросов зависли на челлендже, 0
лотов. Карточки через те же узлы в те же минуты шли.

Замер трёх режимов на одном узле, один и тот же ресурс:

  BFF navigate     ChallengeTimeout, 55с
  BFF subresource  HTTP 200, 52 байта, 6с
  BFF page_fetch   ошибка

Подзапрос проверен вширь: все шесть комнатных корзин HTTP 200, суммарно
6359 офферов против 6367, снятых напрямую с резидентного IP (расхождение
— дрейф фонда за пару часов); список офферов отдаётся на всех четырёх
узлах пула, по 20 штук, 120 КБ.

Сайдкар получил fetch_mode: navigate (дефолт, прежнее поведение),
subresource (context.request.get из прогретого контекста) и page_fetch
(fetch из страницы). Третий оставлен, потому что теоретически он ближе
всего к настоящему XHR, но в замере отказал — выбор сделан измерением, а
не рассуждением. Прогрев origin обязателен: рукопожатие QRATOR попадает
в куки контекста именно при заходе на страницу, поэтому свип теперь
передаёт origin и referer, которых раньше не передавал вовсе.

_decode_body распаковывает gzip по магическим байтам — карта офферов
приезжает Content-Type: application/gzip, и без этого вызывающий получил
бы бинарь в поле "html". Сама карта, к слову, подзапросом всё равно не
берётся (279 байт — загрузчик QRATOR), но BFF полнее: 100% фонда против
62% у карты.

fetch_mode кладётся в payload только когда он не дефолтный — сайдкар
прежней версии не должен получать незнакомый ключ (тот же приём, что с
referer в #3247).

Тесты: сайдкар 212 passed (новый test_server_fetch_mode.py — 6
проверок), backend 316 passed (новый test_3264_sweep_subresource_mode.py
— 5). Три тестовых дублёра _do_fetch/_post_fetch знали старую сигнатуру
— обновлены.
2026-08-30 11:24:28 +03:00

309 lines
14 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.

"""Tests для тёплого переиспользуемого browser-context в DomClick detail-бэкфилле (#3118).
Проблема: sidecar's browser.new_page() создавал НОВЫЙ изолированный context на КАЖДЫЙ
/fetch, поэтому кук-инъекция (см. domclick_session), сделанная ОДИН РАЗ в начале
прогона, не видела Set-Cookie от площадки — живой qrator_jsid2 (~2.5ч TTL) протухал
уже после первого запроса. Живой замер: 26 подряд sidecar-фетчей = 100% блоков, те же
карточки в тёплом browser-контексте = 5/5 успешно.
Фикс: BrowserFetcher(reuse_context=True) держит per-provider context живым между
вызовами; куки вливаются один раз при его создании. Сброс сожжённого context'а
оркестратор просит через bf.request_context_reset() — так как fetch_detail()
(scraper_kit, намеренно не в scope того фикса) не прокидывает reset_context.
#3212 сузил условие сброса: НЕ на каждый блок, а один раз за прогон. Пропуск QRATOR
(qrator_jsid2 + qrator_jsr) живёт в контексте, и сброс его выбрасывает; повторная
валидация с того же IP сразу после принятой получает 403. Сброс на каждый блок поэтому
гарантировал следующий блок — приёмка #3118 и дала «1 успех из 10».
Зеркалит конвенции tests/tasks/test_domclick_detail_backfill.py (module-level
patch-target строки, _mock_db/_mock_session_svc helpers).
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, datetime, timedelta
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 scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402
from app.tasks.domclick_detail_backfill import run_domclick_detail_backfill # noqa: E402
_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail"
_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.domclick_detail_backfill.runs_mod"
_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep"
_SETTINGS = "app.tasks.domclick_detail_backfill.settings"
_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher"
_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc"
def _make_snapshot(n: int) -> list[dict]:
return [
{
"id": i + 1,
"source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}",
}
for i in range(n)
]
def _mock_db(snapshot: list[dict]) -> MagicMock:
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
db.execute.return_value = sel
return db
def _mock_session_svc(cookies: dict[str, str] | None) -> MagicMock:
svc = MagicMock()
svc.load_session.return_value = cookies
svc.COOKIE_EXPIRY_WARN_DAYS = 5
svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30)
return svc
def _mock_browser_fetcher_cls() -> tuple[MagicMock, AsyncMock]:
"""Возвращает (класс-мок, instance-мок) — instance с реальным request_context_reset."""
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
# request_context_reset — синхронный метод (не awaited caller'ом), явный MagicMock
# вместо auto-AsyncMock-атрибута, чтобы вызов не оставлял неawaited coroutine.
instance.request_context_reset = MagicMock()
return MagicMock(return_value=instance), instance
@pytest.mark.asyncio
async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None:
"""BrowserFetcher поднимается с reuse_context=True (#3118) -- тёплый context sidecar'а."""
snapshot = _make_snapshot(1)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(return_value=MagicMock())
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123", "qrator_jsid2": "live"})
mock_bf_cls, _instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_domclick_detail_backfill(
db, run_id=100, params={"batch_size": 10, "budget_sec": 3600}
)
# #3197: конструктор с 08.2026 несёт ещё proxy_provider/use_pool/environment
# (проводка прокси-пула) -- этот тест проверяет только reuse_context, для полной
# проводки см. tests/test_3197_domclick_proxy_pool_wiring.py.
mock_bf_cls.assert_called_once()
_, call_kwargs = mock_bf_cls.call_args
assert call_kwargs["source"] == "domclick"
assert call_kwargs["endpoint"] == fake_settings.browser_http_endpoint
assert call_kwargs["reuse_context"] is True
@pytest.mark.asyncio
async def test_context_reset_requested_at_most_once_per_run() -> None:
"""#3212: сброс контекста запрашивается РОВНО ОДИН раз за прогон, а не на каждый блок.
До #3212 сброс шёл на КАЖДЫЙ блок, и это само себя поддерживало: в контексте лежит
пропуск QRATOR, сброс его выбрасывает, повторная валидация с того же IP сразу после
принятой получает 403 — то есть следующий фетч блокировался гарантированно. Одна
осечка превращалась в необратимый каскад (приёмка #3118: 1 успех из 10).
Здесь 3 подряд блока -> сброс запрошен ровно 1 раз.
"""
snapshot = _make_snapshot(10)
db = _mock_db(snapshot)
runs = MagicMock()
blocked_exc = DomClickBlockedError("browser fetch failed")
mock_fetch = AsyncMock(side_effect=blocked_exc)
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123"})
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_domclick_detail_backfill(
db,
run_id=101,
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
)
assert result.blocked == 3
assert instance.request_context_reset.call_count == 1, (
"ожидалась ровно одна попытка начать с чистого листа за весь прогон"
)
@pytest.mark.asyncio
async def test_successful_fetch_does_not_request_context_reset() -> None:
"""Успешный прогон без блоков -> request_context_reset() ни разу не вызывается."""
snapshot = _make_snapshot(3)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(return_value=MagicMock())
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123"})
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_domclick_detail_backfill(
db, run_id=102, params={"batch_size": 10, "budget_sec": 3600}
)
assert result.blocked == 0
instance.request_context_reset.assert_not_called()
@pytest.mark.asyncio
async def test_block_then_recovery_requests_reset_only_on_the_block() -> None:
"""Блок -> успех -> успех: сброс запрошен РОВНО один раз (на самом блоке).
Один сброс на блок, не на каждый последующий запрос (см. docstring задачи) --
после восстановления (успешный fetch) счётчик подряд-блоков сбрасывается и
request_context_reset() больше не должен вызываться.
"""
snapshot = _make_snapshot(3)
db = _mock_db(snapshot)
runs = MagicMock()
blocked_exc = DomClickBlockedError("browser fetch failed")
mock_fetch = AsyncMock(side_effect=[blocked_exc, MagicMock(), MagicMock()])
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123"})
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_domclick_detail_backfill(
db,
run_id=103,
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
)
assert result.blocked == 1
assert result.enriched == 2
instance.request_context_reset.assert_called_once()
# ── BrowserFetcher: сама логика отложенного флага (#3118) ──────────────────────
# Тесты выше мокают BrowserFetcher целиком и проверяют ОРКЕСТРАТОР («на блок зовём
# request_context_reset ровно один раз»). Ниже — реальный BrowserFetcher с моком
# _post_fetch: проверяем потребление флага, ради которого метод и заведён.
def _fetcher(*, reuse_context: bool):
from scraper_kit.browser_fetcher import BrowserFetcher
bf = BrowserFetcher(
source="domclick", endpoint="http://browser:3000", reuse_context=reuse_context
)
bf._client = MagicMock() # обходим assert «используй как async context manager»
return bf
@pytest.mark.asyncio
async def test_3118_pending_reset_consumed_exactly_once():
"""request_context_reset() влияет РОВНО на один следующий fetch, дальше — обычные."""
bf = _fetcher(reuse_context=True)
seen: list[bool] = []
async def _fake_post(
url, origin=None, cookies=None, reset_context=False, referer=None, fetch_mode=None
):
seen.append(reset_context)
return "<html></html>"
with patch.object(bf, "_post_fetch", side_effect=_fake_post):
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__1")
bf.request_context_reset()
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__2")
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__3")
assert seen == [False, True, False], seen
assert bf._context_reset_pending is False
@pytest.mark.asyncio
async def test_3118_pending_reset_survives_transport_retry():
"""Transport-ошибка → retry идёт с тем же reset (сброс на сайдкаре идемпотентен)."""
import httpx
bf = _fetcher(reuse_context=True)
seen: list[bool] = []
async def _fake_post(
url, origin=None, cookies=None, reset_context=False, referer=None, fetch_mode=None
):
seen.append(reset_context)
if len(seen) == 1:
raise httpx.ConnectError("boom")
return "<html></html>"
with (
patch.object(bf, "_post_fetch", side_effect=_fake_post),
patch("scraper_kit.browser_fetcher.asyncio.sleep", new=AsyncMock()),
):
bf.request_context_reset()
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__4")
assert seen == [True, True], seen
assert bf._context_reset_pending is False
@pytest.mark.asyncio
async def test_3118_payload_unchanged_when_reuse_context_off():
"""reuse_context=False (дефолт) → в payload /fetch новых ключей нет вовсе."""
bf = _fetcher(reuse_context=False)
captured: dict = {}
class _Resp:
@staticmethod
def json():
return {"html": "<html></html>"}
async def _fake_client_post(url, json=None):
captured.update(json or {})
return _Resp()
bf._client.post = _fake_client_post
with (
patch("scraper_kit.browser_fetcher._raise_for_sidecar_status", new=MagicMock()),
patch.object(bf, "_report_fetch_result", new=MagicMock()),
):
bf.request_context_reset() # no-op при выключенном флаге
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__5")
assert "reuse_context" not in captured
assert "reset_context" not in captured