diff --git a/tradein-mvp/backend/tests/test_browser_pool_routing.py b/tradein-mvp/backend/tests/test_browser_pool_routing.py deleted file mode 100644 index 8a3b5ce8..00000000 --- a/tradein-mvp/backend/tests/test_browser_pool_routing.py +++ /dev/null @@ -1,240 +0,0 @@ -"""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 "yandex" - - 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"] == "yandex" - 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 "fallback" - - 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": "single"}) - - 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"] == "single" - 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