"""test_server.py — юниты per-provider concurrency для tradein-browser (#1793). Проверяет ядро per-provider модели: 1. _provider_for_url — корректный маппинг host → provider (avito/cian/yandex/generic); 2. _provider_proxy — SCRAPER_PROXY_URL, общий для всех поставщиков (#2616 шаг 2: per-provider BROWSER_PROXY_*/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" # Битый / пустой URL — без хоста → generic, не падаем. assert server._provider_for_url("not-a-url") == "generic" assert server._provider_for_url("") == "generic" def test_provider_for_url_domclick() -> None: """#3205: у ДомКлика собственный провайдер, а не generic (свой recycle-порог).""" assert server._provider_for_url("https://domclick.ru/x") == "domclick" assert server._provider_for_url("https://ekaterinburg.domclick.ru/card/1") == "domclick" # ── _provider_proxy ────────────────────────────────────────────────────────────── def test_provider_proxy_shared_across_providers(monkeypatch: pytest.MonkeyPatch) -> None: """#2616 шаг 2: SCRAPER_PROXY_URL — единственный источник, общий для всех.""" monkeypatch.setenv("SCRAPER_PROXY_URL", "http://u:p@shared-proxy:8080") assert server._provider_proxy("avito") == "http://u:p@shared-proxy:8080" assert server._provider_proxy("cian") == "http://u:p@shared-proxy:8080" assert server._provider_proxy("yandex") == "http://u:p@shared-proxy:8080" assert server._provider_proxy("generic") == "http://u:p@shared-proxy:8080" def test_provider_proxy_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None: """SCRAPER_PROXY_URL не задан → None (direct, как одиночный браузер без прокси).""" monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) assert server._provider_proxy("cian") is None # ── _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_falls_back_to_host_detect() -> None: """#3205: domclick scraper шлёт source='domklik' (опечатка, нет в PROVIDERS). 'domklik' не валиден как explicit → host-detect domclick.ru → выделенный провайдер "domclick" (собственный instance + recycle_pages=1), а НЕ generic. """ assert ( server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "domclick" ) # ── _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, referer: str | None = None, cookies: dict | None = None, reuse_context: bool = False, reset_context: bool = False, fetch_mode: str = "navigate", behaviour: object = 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"{provider}" 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 пишется.""" # Интервал задаём в _MIN_PAGE_INTERVAL_BY_PROVIDER — ИМЕННО его читает # _pace_provider (per-provider override, #1812 follow-up). Модульный # BROWSER_MIN_PAGE_INTERVAL_S там только фолбэк для провайдера вне словаря, # так что патч глобала на известном провайдере не влияет ни на что. monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 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.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "cian", 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.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "yandex", 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: """Эффективный интервал провайдера <= 0 → пейсинг выключен, timestamp не пишется.""" monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 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.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 2.0) monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "cian", 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 == [], "разные провайдеры не пейсят друг друга" # ── #3185: _resolve_block_images (per-provider block_images) ─────────────────────── def test_resolve_block_images_default_avito_blocked() -> None: """Код-дефолт: avito блокирует картинки — как и все (откат #3185, см. server.py).""" assert server._resolve_block_images("avito", environ={}) is True def test_resolve_block_images_default_others_blocked() -> None: """Код-дефолт: cian/yandex/generic блокируют — поведение не менялось никогда.""" assert server._resolve_block_images("cian", environ={}) is True assert server._resolve_block_images("yandex", environ={}) is True assert server._resolve_block_images("generic", environ={}) is True def test_resolve_block_images_global_env_overrides_default_both_ways() -> None: """Глобальный BROWSER_BLOCK_IMAGES перебивает код-дефолт для ЛЮБОГО провайдера.""" assert server._resolve_block_images("avito", environ={"BROWSER_BLOCK_IMAGES": "true"}) is True assert server._resolve_block_images("cian", environ={"BROWSER_BLOCK_IMAGES": "false"}) is False def test_resolve_block_images_per_provider_env_overrides_global() -> None: """Per-provider BROWSER_BLOCK_IMAGES_{PROVIDER} сильнее и global env, и дефолта.""" env = {"BROWSER_BLOCK_IMAGES": "false", "BROWSER_BLOCK_IMAGES_CIAN": "true"} assert server._resolve_block_images("cian", environ=env) is True # avito не упомянут per-provider'ом → берёт global (false). assert server._resolve_block_images("avito", environ=env) is False def test_resolve_block_images_per_provider_env_overrides_avito_default() -> None: """BROWSER_BLOCK_IMAGES_AVITO=false снимает блокировку для avito, не трогая прочих. Проверка направления, которое ОТЛИЧАЕТСЯ от дефолта: после отката #3185 дефолт для всех True, поэтому тест с env=true проходил бы и у функции, всегда возвращающей True. Ручка нужна именно чтобы вернуть avito=False без релиза — это и проверяем. """ env = {"BROWSER_BLOCK_IMAGES_AVITO": "false"} assert server._resolve_block_images("avito", environ=env) is False assert server._resolve_block_images("cian", environ=env) is True def test_resolve_block_images_unrecognized_value_is_falsy() -> None: """Нераспознанная строка per-provider env — как везде в _parse_bool: не True.""" env = {"BROWSER_BLOCK_IMAGES_CIAN": "not-a-bool"} assert server._resolve_block_images("cian", environ=env) is False # ── #2674: отставные переменные окружения ──────────────────────────────────── # BROWSER_BLOCK_RESOURCES стоит =true во всех трёх прод-контейнерах, а код перестал # её читать в #1812 (булев выключатель → BROWSER_BLOCK_RESOURCE_TYPES). Блокировка при # этом не ослабла (image глушит camoufox block_images, font/media — дефолт списка), но # сама ручка мертва: выставив =false, оператор ничего не выключит и сделает вывод о # блокировке, а не о переменной. Сервис обязан сказать это вслух на старте. def test_retired_env_warns_when_set( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: monkeypatch.setenv("BROWSER_BLOCK_RESOURCES", "false") with caplog.at_level("WARNING"): server._warn_retired_env() assert any("BROWSER_BLOCK_RESOURCES" in r.getMessage() for r in caplog.records), ( "мёртвая переменная выставлена, а сервис молчит" ) def test_retired_env_silent_when_unset( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: monkeypatch.delenv("BROWSER_BLOCK_RESOURCES", raising=False) with caplog.at_level("WARNING"): server._warn_retired_env() assert caplog.records == [], "предупреждение без повода — это шум, а не сигнал" def test_retired_env_does_not_change_blocking(monkeypatch: pytest.MonkeyPatch) -> None: """Отставная переменная остаётся отставной: блокировкой правит только список типов.""" monkeypatch.setenv("BROWSER_BLOCK_RESOURCES", "false") assert server._parse_block_types(None) == frozenset({"font", "media"}) async def _coro(value: Any) -> Any: return value