gendesign/tradein-mvp/backend/tests/test_browser_pool_routing.py
bot-backend b9eb478c8f
All checks were successful
CI / changes (pull_request) Successful in 6s
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
feat(browser): per-source proxy pool behind FEATURE_BROWSER_POOL_ENABLED (Phase 1)
Route each scraper source (avito/cian/yandex/domclick) to its own camoufox
browser+proxy so they no longer wedge each other through a single global egress.

Feature-flagged (FEATURE_BROWSER_POOL_ENABLED, default OFF): with the flag off the
/fetch and /login paths are byte-for-byte the existing single-browser behavior.
When on, /fetch routes by body["source"] to a per-proxy browser via BROWSER_PROXY_MAP
(BROWSER_PROXY_AVITO/CIAN/YANDEX/DOMCLICK with legacy fallbacks), each guarded by its
own lazy-launched lock. /login stays single-browser in Phase 1.

BrowserFetcher gains a source arg (default avito) and sends it in the /fetch body;
all scraper callsites pass their source. No docker-compose/.env.runtime changes
(Phase 2, owner-gated).
2026-06-18 09:01:50 +03:00

240 lines
9.9 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.

"""test_browser_pool_routing.py — per-source proxy pool routing (Phase 1, #browser-pool).
Юнит-тесты server.fetch_handler с включённым/выключенным FEATURE_BROWSER_POOL_ENABLED.
camoufox не нужен: launch/_do_fetch_pool замоканы.
server.py живёт в tradein-mvp/browser/ (отдельный сервис) и импортит `from aiohttp
import web`. aiohttp в backend-venv НЕТ (только в browser-образе), поэтому здесь в
sys.modules инжектится минимальный стаб aiohttp.web ДО загрузки server.py — ровно те
имена, что использует модуль (Application/Request/Response/json_response). Так
routing-логика гоняется прямо в backend pytest-gate без тяжёлой aiohttp-зависимости.
Проверяем:
- флаг ON → fetch_handler роутит по body["source"] на нужный proxy_url и держит
его lock во время _do_fetch_pool (замоканного);
- неизвестный/отсутствующий source → fallback на avito-прокси;
- launch недоступного прокси → 503;
- флаг OFF → берётся одиночный путь (_fetch_via_single), source игнорируется;
- _build_proxy_map отбрасывает пустые env и применяет legacy-fallback.
"""
from __future__ import annotations
import asyncio
import importlib.util
import json
import sys
import types
from pathlib import Path
from typing import Any
import pytest
# ── минимальный стаб aiohttp.web (server.py юзает только эти имена) ──────────────
class _StubResponse:
"""Имитация web.Response от json_response: хранит status + JSON-байты в .body."""
def __init__(self, payload: dict[str, Any], status: int = 200) -> None:
self.status = status
self.body = json.dumps(payload).encode()
def _json_response(payload: dict[str, Any], status: int = 200) -> _StubResponse:
return _StubResponse(payload, status=status)
def _install_aiohttp_stub() -> None:
"""Кладёт фейковый aiohttp + aiohttp.web в sys.modules, если настоящего нет."""
try:
import aiohttp # noqa: F401
return # настоящий aiohttp доступен — стаб не нужен
except ModuleNotFoundError:
pass
web = types.ModuleType("aiohttp.web")
web.Application = type("Application", (), {}) # type: ignore[attr-defined]
web.Request = type("Request", (), {}) # type: ignore[attr-defined]
web.Response = _StubResponse # type: ignore[attr-defined]
web.json_response = _json_response # type: ignore[attr-defined]
aiohttp_mod = types.ModuleType("aiohttp")
aiohttp_mod.web = web # type: ignore[attr-defined]
sys.modules["aiohttp"] = aiohttp_mod
sys.modules["aiohttp.web"] = web
_install_aiohttp_stub()
# server.py — отдельный сервис вне backend-пакета. Грузим по относительному пути.
_SERVER_PATH = Path(__file__).resolve().parents[2] / "browser" / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server_pool", _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)
class _StubRequest:
"""Минимальный stub aiohttp-request: handler дёргает только .json()."""
def __init__(self, body: dict[str, Any]) -> None:
self._body = body
async def json(self) -> dict[str, Any]:
return self._body
def _json_body(response: Any) -> dict[str, Any]:
return json.loads(response.body.decode())
def _make_fetch_request(body: dict[str, Any]) -> Any:
return _StubRequest(body)
# ── флаг ON: routing по source ───────────────────────────────────────────────────
def test_pool_routes_by_source_and_holds_proxy_lock(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""ON: source=yandex → fetch на yandex-прокси, держа именно его lock."""
proxy_map = {
"avito": "http://avito-proxy:1",
"cian": "http://cian-proxy:2",
"yandex": "http://yandex-proxy:3",
}
monkeypatch.setattr(server, "FEATURE_BROWSER_POOL_ENABLED", True)
monkeypatch.setattr(server, "BROWSER_PROXY_MAP", proxy_map)
monkeypatch.setattr(server, "_pool_locks", {})
monkeypatch.setattr(server, "_pool_locks_guard", asyncio.Lock())
seen: dict[str, Any] = {}
async def _fake_get_or_launch(proxy_url: str) -> object:
seen["launched_proxy"] = proxy_url
return object() # «браузер поднят»
async def _fake_do_fetch_pool(url: str, proxy_url: str) -> str:
seen["fetch_proxy"] = proxy_url
# lock этого proxy_url должен быть удержан вызывающим (handler).
seen["lock_held"] = server._pool_locks[proxy_url].locked()
return "<html>yandex</html>"
monkeypatch.setattr(server, "_get_or_launch_pool_browser", _fake_get_or_launch)
monkeypatch.setattr(server, "_do_fetch_pool", _fake_do_fetch_pool)
request = _make_fetch_request({"url": "https://realty.yandex.ru/x", "source": "yandex"})
response = asyncio.run(server.fetch_handler(request))
assert response.status == 200
assert _json_body(response)["html"] == "<html>yandex</html>"
assert seen["launched_proxy"] == "http://yandex-proxy:3"
assert seen["fetch_proxy"] == "http://yandex-proxy:3"
assert seen["lock_held"] is True
assert "http://yandex-proxy:3" in server._pool_locks
def test_pool_unknown_source_falls_back_to_avito(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""ON: domclick без своего прокси → fallback на avito-прокси."""
proxy_map = {"avito": "http://avito-proxy:1"} # нет domclick
monkeypatch.setattr(server, "FEATURE_BROWSER_POOL_ENABLED", True)
monkeypatch.setattr(server, "BROWSER_PROXY_MAP", proxy_map)
monkeypatch.setattr(server, "_pool_locks", {})
monkeypatch.setattr(server, "_pool_locks_guard", asyncio.Lock())
seen: dict[str, Any] = {}
async def _fake_get_or_launch(proxy_url: str) -> object:
return object()
async def _fake_do_fetch_pool(url: str, proxy_url: str) -> str:
seen["fetch_proxy"] = proxy_url
return "<html>fallback</html>"
monkeypatch.setattr(server, "_get_or_launch_pool_browser", _fake_get_or_launch)
monkeypatch.setattr(server, "_do_fetch_pool", _fake_do_fetch_pool)
request = _make_fetch_request({"url": "https://domclick.ru/x", "source": "domclick"})
response = asyncio.run(server.fetch_handler(request))
assert response.status == 200
assert seen["fetch_proxy"] == "http://avito-proxy:1"
def test_pool_returns_503_when_browser_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""ON: launch недоступного прокси → None → 503 (та же форма, что одиночный путь)."""
monkeypatch.setattr(server, "FEATURE_BROWSER_POOL_ENABLED", True)
monkeypatch.setattr(server, "BROWSER_PROXY_MAP", {"avito": "http://avito-proxy:1"})
monkeypatch.setattr(server, "_pool_locks", {})
monkeypatch.setattr(server, "_pool_locks_guard", asyncio.Lock())
async def _fake_get_or_launch(proxy_url: str) -> None:
return None # прокси лежит
monkeypatch.setattr(server, "_get_or_launch_pool_browser", _fake_get_or_launch)
request = _make_fetch_request({"url": "https://avito.ru/x", "source": "avito"})
response = asyncio.run(server.fetch_handler(request))
assert response.status == 503
assert "browser unavailable" in _json_body(response)["error"]
# ── флаг OFF: одиночный путь, source игнорируется ────────────────────────────────
def test_flag_off_uses_single_path_and_ignores_source(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""OFF: берётся _fetch_via_single, pool-путь НЕ вызывается, source неважен."""
monkeypatch.setattr(server, "FEATURE_BROWSER_POOL_ENABLED", False)
single_called = {"n": 0}
async def _fake_single(url: str) -> Any:
single_called["n"] += 1
return server.web.json_response({"html": "<html>single</html>"})
async def _fail_pool(url: str, source: str | None) -> Any:
raise AssertionError("pool path не должен вызываться при OFF-флаге")
monkeypatch.setattr(server, "_fetch_via_single", _fake_single)
monkeypatch.setattr(server, "_fetch_via_pool", _fail_pool)
request = _make_fetch_request({"url": "https://avito.ru/x", "source": "yandex"})
response = asyncio.run(server.fetch_handler(request))
assert response.status == 200
assert _json_body(response)["html"] == "<html>single</html>"
assert single_called["n"] == 1
def test_build_proxy_map_drops_empties(monkeypatch: pytest.MonkeyPatch) -> None:
"""_build_proxy_map: пустые/None env не попадают в map; legacy-fallback работает."""
for var in (
"BROWSER_PROXY_AVITO",
"AVITO_PROXY_URL",
"SCRAPER_PROXY_URL",
"BROWSER_PROXY_CIAN",
"CIAN_PROXY_URL",
"BROWSER_PROXY_YANDEX",
"YANDEX_PROXY_URL",
"BROWSER_PROXY_DOMCLICK",
):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("AVITO_PROXY_URL", "http://avito:1") # avito через legacy-fallback
monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://cian:2")
# yandex/domclick не заданы → отсутствуют в map.
result = server._build_proxy_map()
assert result == {"avito": "http://avito:1", "cian": "http://cian:2"}
assert "yandex" not in result
assert "domclick" not in result