gendesign/tradein-mvp/browser/test_server.py
lekss361 ced616d150
All checks were successful
Deploy Trade-In / test (push) Successful in 49s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m13s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 7m16s
feat(tradein/domclick): cookie-injection MVP для обхода QRATOR-блока (#2000) (#2433)
2026-07-04 17:49:40 +00:00

524 lines
23 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.

"""test_server.py — юниты per-provider concurrency для tradein-browser (#1793).
Проверяет ядро per-provider модели:
1. _provider_for_url — корректный маппинг host → provider (avito/cian/yandex/generic);
2. _provider_proxy — BROWSER_PROXY_* primary с фолбэком на legacy *_PROXY_URL;
3. _resolve_provider — явный body["provider"] перебивает host, иначе host;
4. конкурентность: два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи),
два к ОДНОМУ — сериализованы (один лок). Через _do_fetch с asyncio.Event и
замером перекрытия по времени.
camoufox НЕ запускается: _ensure_browser / _do_fetch мокаются. Хендлер вызывается
напрямую (make_mocked_request не поднимает сокет).
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
import json
from pathlib import Path
from typing import Any
import pytest
from aiohttp.test_utils import make_mocked_request
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
def _json_body(response: Any) -> dict[str, Any]:
return json.loads(response.body.decode())
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Чистое per-provider состояние + инициализированный _locks_guard на каждый тест."""
monkeypatch.setattr(server, "_browsers", {})
monkeypatch.setattr(server, "_browser_cms", {})
monkeypatch.setattr(server, "_page_counters", {})
monkeypatch.setattr(server, "_locks", {})
monkeypatch.setattr(server, "_retry_tasks", {})
monkeypatch.setattr(server, "_last_goto_at", {})
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
# ── _provider_for_url ────────────────────────────────────────────────────────────
def test_provider_for_url_avito() -> None:
assert server._provider_for_url("https://www.avito.ru/moskva/kvartiry") == "avito"
assert server._provider_for_url("https://m.avito.ru/item/123") == "avito"
def test_provider_for_url_cian() -> None:
assert server._provider_for_url("https://www.cian.ru/sale/flat/1/") == "cian"
assert server._provider_for_url("https://spb.cian.ru/x") == "cian"
def test_provider_for_url_yandex() -> None:
assert server._provider_for_url("https://realty.yandex.ru/offer/1/") == "yandex"
def test_provider_for_url_generic_fallback() -> None:
assert server._provider_for_url("https://example.com/x") == "generic"
assert server._provider_for_url("https://domclick.ru/x") == "generic"
# Битый / пустой URL — без хоста → generic, не падаем.
assert server._provider_for_url("not-a-url") == "generic"
assert server._provider_for_url("") == "generic"
# ── _provider_proxy ──────────────────────────────────────────────────────────────
def test_provider_proxy_uses_browser_proxy_primary(monkeypatch: pytest.MonkeyPatch) -> None:
"""BROWSER_PROXY_* — primary источник для каждого поставщика."""
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://u:p@avito-proxy:8080")
monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://u:p@cian-proxy:8080")
monkeypatch.setenv("BROWSER_PROXY_YANDEX", "http://u:p@yandex-proxy:8080")
assert server._provider_proxy("avito") == "http://u:p@avito-proxy:8080"
assert server._provider_proxy("cian") == "http://u:p@cian-proxy:8080"
assert server._provider_proxy("yandex") == "http://u:p@yandex-proxy:8080"
def test_provider_proxy_falls_back_to_legacy(monkeypatch: pytest.MonkeyPatch) -> None:
"""Без BROWSER_PROXY_* берётся legacy *_PROXY_URL фолбэк."""
monkeypatch.delenv("BROWSER_PROXY_AVITO", raising=False)
monkeypatch.delenv("BROWSER_PROXY_CIAN", raising=False)
monkeypatch.delenv("BROWSER_PROXY_YANDEX", raising=False)
monkeypatch.setenv("AVITO_PROXY_URL", "http://u:p@legacy-avito:8080")
monkeypatch.setenv("CIAN_PROXY_URL", "http://u:p@legacy-cian:8080")
monkeypatch.setenv("YANDEX_PROXY_URL", "http://u:p@legacy-yandex:8080")
assert server._provider_proxy("avito") == "http://u:p@legacy-avito:8080"
assert server._provider_proxy("cian") == "http://u:p@legacy-cian:8080"
assert server._provider_proxy("yandex") == "http://u:p@legacy-yandex:8080"
def test_provider_proxy_generic(monkeypatch: pytest.MonkeyPatch) -> None:
"""generic: SCRAPER_PROXY_URL primary, AVITO_PROXY_URL фолбэк."""
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
monkeypatch.setenv("AVITO_PROXY_URL", "http://u:p@avito:8080")
assert server._provider_proxy("generic") == "http://u:p@avito:8080"
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://u:p@scraper:8080")
assert server._provider_proxy("generic") == "http://u:p@scraper:8080"
def test_provider_proxy_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ни одной env → None (direct, как одиночный браузер без прокси)."""
for var in (
"BROWSER_PROXY_CIAN",
"CIAN_PROXY_URL",
):
monkeypatch.delenv(var, raising=False)
assert server._provider_proxy("cian") is None
def test_provider_proxy_browser_proxy_wins_over_legacy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""BROWSER_PROXY_* перебивает legacy *_PROXY_URL (http vs возможный socks5)."""
monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://u:p@http-cian:8080")
monkeypatch.setenv("CIAN_PROXY_URL", "socks5://u:p@socks-cian:1080")
assert server._provider_proxy("cian") == "http://u:p@http-cian:8080"
# ── _resolve_provider ────────────────────────────────────────────────────────────
def test_resolve_provider_by_host() -> None:
assert server._resolve_provider({}, "https://www.avito.ru/x") == "avito"
assert server._resolve_provider({}, "https://www.cian.ru/x") == "cian"
def test_resolve_provider_explicit_overrides_host() -> None:
"""Явный валидный body["provider"] перебивает host."""
assert server._resolve_provider({"provider": "yandex"}, "https://www.avito.ru/x") == "yandex"
def test_resolve_provider_invalid_explicit_falls_back_to_host() -> None:
"""Невалидный body["provider"] игнорируется → берём по host."""
assert server._resolve_provider({"provider": "bogus"}, "https://www.cian.ru/x") == "cian"
assert server._resolve_provider({"provider": 123}, "https://www.cian.ru/x") == "cian"
def test_resolve_provider_uses_source_field() -> None:
"""browser_fetcher шлёт {"url","source"} — explicit берётся и из "source"."""
assert server._resolve_provider({"source": "avito"}, "https://example.com/x") == "avito"
assert server._resolve_provider({"source": "yandex"}, "https://www.avito.ru/x") == "yandex"
def test_resolve_provider_domclick_source_to_generic() -> None:
"""domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru.
'domklik' не валиден → host-detect domclick.ru → generic. Намеренно: в prod
нет выделенного BROWSER_PROXY_DOMCLICK.
"""
assert (
server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "generic"
)
# ── _lock_for: один лок на поставщика ────────────────────────────────────────────
def test_lock_for_is_stable_per_provider() -> None:
"""_lock_for возвращает один и тот же объект-лок для поставщика, разные — для разных."""
async def _run() -> tuple[Any, Any, Any]:
a1 = await server._lock_for("avito")
a2 = await server._lock_for("avito")
c1 = await server._lock_for("cian")
return a1, a2, c1
a1, a2, c1 = asyncio.run(_run())
assert a1 is a2, "лок одного поставщика стабилен"
assert a1 is not c1, "локи разных поставщиков различны"
# ── concurrency: параллель между поставщиками, сериализация внутри ───────────────
def _make_fetch_request(url: str) -> Any:
request = make_mocked_request("POST", "/fetch")
request.json = lambda: _coro({"url": url}) # type: ignore[method-assign]
return request
class _OverlapProbe:
"""Мок _do_fetch: меряет максимальное число одновременно активных fetch per-provider."""
def __init__(self, hold: float = 0.05) -> None:
self.hold = hold
self.active: dict[str, int] = {}
self.peak: dict[str, int] = {}
self.global_active = 0
self.global_peak = 0
async def __call__(
self,
provider: str,
url: str,
*,
origin: str | None = None,
cookies: dict | None = None,
) -> str:
self.active[provider] = self.active.get(provider, 0) + 1
self.peak[provider] = max(self.peak.get(provider, 0), self.active[provider])
self.global_active += 1
self.global_peak = max(self.global_peak, self.global_active)
try:
await asyncio.sleep(self.hold)
finally:
self.active[provider] -= 1
self.global_active -= 1
return f"<html>{provider}</html>"
def test_same_provider_fetches_are_serialized(monkeypatch: pytest.MonkeyPatch) -> None:
"""Два /fetch к ОДНОМУ поставщику сериализованы (один лок) — peak==1."""
probe = _OverlapProbe()
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
server._browsers[provider] = object()
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
monkeypatch.setattr(server, "_do_fetch", probe)
async def _run() -> None:
await asyncio.gather(
server.fetch_handler(_make_fetch_request("https://www.avito.ru/a")),
server.fetch_handler(_make_fetch_request("https://www.avito.ru/b")),
server.fetch_handler(_make_fetch_request("https://www.avito.ru/c")),
)
asyncio.run(_run())
assert probe.peak["avito"] == 1, "внутри поставщика строго ≤1 одновременный fetch"
def test_different_providers_fetch_concurrently(monkeypatch: pytest.MonkeyPatch) -> None:
"""Два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи) — global peak>1."""
probe = _OverlapProbe()
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
server._browsers[provider] = object()
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
monkeypatch.setattr(server, "_do_fetch", probe)
async def _run() -> list[Any]:
return await asyncio.gather(
server.fetch_handler(_make_fetch_request("https://www.avito.ru/a")),
server.fetch_handler(_make_fetch_request("https://www.cian.ru/b")),
server.fetch_handler(_make_fetch_request("https://realty.yandex.ru/c")),
)
responses = asyncio.run(_run())
assert all(r.status == 200 for r in responses)
# >=2 (не ==3): на нагруженном CI планировщик может не вывести все три корутины
# в overlap одновременно; перекрытие хотя бы двух уже доказывает параллельность
# между поставщиками. Same-provider тест (peak==1) остаётся строгим.
assert probe.global_peak >= 2, "разные поставщики должны фетчить конкурентно"
# У каждого поставщика по одному запросу — внутренний peak == 1.
assert probe.peak == {"avito": 1, "cian": 1, "yandex": 1}
def test_mixed_load_serializes_within_parallelizes_across(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Смешанная нагрузка: avito×2 сериализованы, но параллельны с cian×1."""
probe = _OverlapProbe()
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
server._browsers[provider] = object()
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
monkeypatch.setattr(server, "_do_fetch", probe)
async def _run() -> None:
await asyncio.gather(
server.fetch_handler(_make_fetch_request("https://www.avito.ru/a")),
server.fetch_handler(_make_fetch_request("https://www.avito.ru/b")),
server.fetch_handler(_make_fetch_request("https://www.cian.ru/c")),
)
asyncio.run(_run())
assert probe.peak["avito"] == 1, "avito сериализован сам с собой"
assert probe.peak["cian"] == 1
assert probe.global_peak >= 2, "avito и cian должны перекрываться во времени"
# ── _parse_block_types: env-парсинг ───────────────────────────────────────────────
def test_parse_block_types_default() -> None:
"""None (env не задана) → дефолт font,media."""
assert server._parse_block_types(None) == frozenset({"font", "media"})
def test_parse_block_types_empty_disables() -> None:
"""Пустая строка → пустой набор (ничего не блокируем)."""
assert server._parse_block_types("") == frozenset()
assert server._parse_block_types(" ") == frozenset()
assert server._parse_block_types(" , , ") == frozenset()
def test_parse_block_types_custom_csv() -> None:
"""CSV парсится с trim/lower, дубликаты схлопываются."""
assert server._parse_block_types("font, Media , font") == frozenset({"font", "media"})
assert server._parse_block_types("FONT,IMAGE") == frozenset({"font", "image"})
# ── route-handler: блокировка font/media, пропуск document/script/css/xhr ──────────
class _FakeRequest:
def __init__(self, resource_type: str) -> None:
self.resource_type = resource_type
class _FakeRoute:
"""Мок Playwright Route: фиксирует, был ли abort или continue_."""
def __init__(self, resource_type: str) -> None:
self.request = _FakeRequest(resource_type)
self.aborted = False
self.continued = False
async def abort(self) -> None:
self.aborted = True
async def continue_(self) -> None:
self.continued = True
class _CapturePage:
"""Мок page: ловит handler, переданный в page.route, чтобы дёрнуть его напрямую."""
def __init__(self) -> None:
self.handler: Any = None
self.route_pattern: Any = None
async def route(self, pattern: str, handler: Any) -> None:
self.route_pattern = pattern
self.handler = handler
def test_resource_block_aborts_font_and_media(monkeypatch: pytest.MonkeyPatch) -> None:
"""Дефолтный набор font/media → abort; document/script/stylesheet/xhr/fetch → continue."""
monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset({"font", "media"}))
page = _CapturePage()
asyncio.run(server._apply_resource_block(page))
assert page.handler is not None, "route-handler должен быть навешен"
assert page.route_pattern == "**/*"
async def _check(resource_type: str) -> _FakeRoute:
route = _FakeRoute(resource_type)
await page.handler(route)
return route
# Блокируемые типы → abort
for rt in ("font", "media"):
route = asyncio.run(_check(rt))
assert route.aborted is True and route.continued is False, f"{rt} должен abort'иться"
# Критичные для extraction/fingerprint типы → continue
for rt in ("document", "script", "stylesheet", "xhr", "fetch", "image"):
route = asyncio.run(_check(rt))
assert route.continued is True and route.aborted is False, f"{rt} должен проходить"
def test_resource_block_noop_when_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""Пустой _BLOCKED_TYPES → page.route не навешивается (no-op)."""
monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset())
page = _CapturePage()
asyncio.run(server._apply_resource_block(page))
assert page.handler is None, "при пустом наборе route навешивать не должны"
def test_resource_block_handler_degrades_to_continue(monkeypatch: pytest.MonkeyPatch) -> None:
"""Сбой abort() деградирует в continue_() — битый ресурс не рвёт навигацию."""
monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset({"font"}))
page = _CapturePage()
asyncio.run(server._apply_resource_block(page))
class _FlakyRoute(_FakeRoute):
async def abort(self) -> None:
raise RuntimeError("route already handled")
route = _FlakyRoute("font")
asyncio.run(page.handler(route))
assert route.continued is True, "при сбое abort должны fallback'нуть в continue_()"
# ── _pace_provider: межстраничный пейсинг одного провайдера ────────────────────────
def test_pace_provider_first_call_no_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
"""Первый goto провайдера — без ожидания, но timestamp пишется."""
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0)
slept: list[float] = []
async def _fake_sleep(d: float) -> None:
slept.append(d)
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
asyncio.run(server._pace_provider("avito"))
assert slept == [], "первый вызов не должен спать"
assert "avito" in server._last_goto_at
def test_pace_provider_waits_remaining_interval(monkeypatch: pytest.MonkeyPatch) -> None:
"""Второй goto в пределах интервала → sleep на остаток (interval - elapsed)."""
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0)
slept: list[float] = []
async def _fake_sleep(d: float) -> None:
slept.append(d)
# _pace_provider читает time() дважды за вызов (now + запись last).
# 1-й вызов: now=100.0, last←100.0. 2-й: now=100.5 (elapsed=0.5→remaining=1.5),
# после sleep last←102.0.
clock = iter([100.0, 100.0, 100.5, 102.0])
class _FakeLoop:
def time(self) -> float:
return next(clock)
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: _FakeLoop())
async def _run() -> None:
await server._pace_provider("cian") # первый — last=100.0
await server._pace_provider("cian") # второй — elapsed=0.5, remaining=1.5
asyncio.run(_run())
assert len(slept) == 1, "второй вызов должен один раз sleep'нуть"
assert slept[0] == pytest.approx(1.5), "ждём остаток интервала (2.0 - 0.5)"
def test_pace_provider_no_sleep_when_interval_elapsed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Если с прошлого goto прошло >= интервала → не спим."""
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0)
slept: list[float] = []
async def _fake_sleep(d: float) -> None:
slept.append(d)
# 2 чтения time() на вызов: [now1, last1, now2, last2]; elapsed=5.0 > 2.0 → no sleep.
clock = iter([100.0, 100.0, 105.0, 105.0])
class _FakeLoop:
def time(self) -> float:
return next(clock)
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: _FakeLoop())
async def _run() -> None:
await server._pace_provider("yandex")
await server._pace_provider("yandex") # elapsed=5.0 > 2.0 → no sleep
asyncio.run(_run())
assert slept == [], "интервал уже прошёл — спать не нужно"
def test_pace_provider_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> None:
"""BROWSER_MIN_PAGE_INTERVAL_S <= 0 → пейсинг выключен, timestamp не пишется."""
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0)
slept: list[float] = []
async def _fake_sleep(d: float) -> None:
slept.append(d)
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
async def _run() -> None:
await server._pace_provider("avito")
await server._pace_provider("avito")
asyncio.run(_run())
assert slept == []
assert "avito" not in server._last_goto_at
def test_pace_provider_independent_per_provider(monkeypatch: pytest.MonkeyPatch) -> None:
"""Timestamp независим по провайдерам — пейсинг avito не влияет на cian."""
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0)
slept: list[float] = []
async def _fake_sleep(d: float) -> None:
slept.append(d)
# 2 чтения time() на вызов: avito [100.0, 100.0]; cian первый [100.1, 100.1]
# (нет last для cian → no sleep).
clock = iter([100.0, 100.0, 100.1, 100.1])
class _FakeLoop:
def time(self) -> float:
return next(clock)
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: _FakeLoop())
async def _run() -> None:
await server._pace_provider("avito")
await server._pace_provider("cian")
asyncio.run(_run())
assert slept == [], "разные провайдеры не пейсят друг друга"
async def _coro(value: Any) -> Any:
return value