gendesign/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py
bot-backend 5be64c6688
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m18s
Deploy Trade-In / test (push) Successful in 4m4s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 2m0s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
fix(tradein/scrapers): хранилище авторизованной сессии Яндекс.Недвижимости (#3195)
2026-08-28 19:02:05 +00:00

295 lines
12 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.

"""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 живым между
вызовами; куки вливаются один раз при его создании. При обнаруженном блоке следующий
fetch должен сбросить сожжённый context — так как fetch_detail() (scraper_kit,
намеренно не в scope этого фикса) не прокидывает reset_context, оркестратор сигналит
через bf.request_context_reset() (BrowserFetcher: отложенный флаг, потребляется РОВНО
одним следующим fetch()).
Зеркалит конвенции 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}
)
mock_bf_cls.assert_called_once_with(
source="domclick", endpoint=fake_settings.browser_http_endpoint, reuse_context=True
)
@pytest.mark.asyncio
async def test_block_triggers_exactly_one_context_reset_request() -> None:
"""DomClickBlockedError -> bf.request_context_reset() вызывается РОВНО один раз на блок.
3 подряд блока (max_consecutive_blocks=3) -> сброс запрашивается 3 раза (по разу
НА КАЖДЫЙ обнаруженный блок), а не один раз на весь прогон и не на каждый успешный
fetch между блоками.
"""
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 == 3, (
"ожидался ровно один запрос сброса НА КАЖДЫЙ обнаруженный блок"
)
@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):
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):
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