camoufox 0.5.5 при geoip=True кладёт координаты из GeoIP-записи exit-IP безусловно, а timezone — через setdefault, поэтому ручной config["timezone"]="Europe/Moscow" перебивал зону из IP. Узлы пула 13 и 15 (T2 NSK, UTC+7) и 14 (МегаФон Самара, UTC+4, RIPE 17.09) заявляли МСК при сибирских/самарских координатах, а библиотека писала LeakWarning на каждом запуске (4715 штук за 13 ч при 665 запусках). Ротация узла идёт внутри подсети оператора, смена узла релончит браузер — зона с запуска не протухает. Тест: в kwargs запуска нет ключей timezone/geolocation:* в config, geoip=True. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
8.4 KiB
Python
194 lines
8.4 KiB
Python
"""test_server_proxy_override.py — динамический прокси из тела /fetch (#2164 P4).
|
||
|
||
Проверяет:
|
||
1. _resolve_proxy_override — берёт body["proxy"] (kind http/https), None если нет /
|
||
если proxy_kind несовместим (socks5 → camoufox не умеет);
|
||
2. политика relaunch «только при реальной смене прокси» через _ensure_browser:
|
||
- инстанса нет → launch с proxy_override;
|
||
- тот же прокси → НЕ релончим (launch не зовётся повторно);
|
||
- другой прокси → close + relaunch с новым;
|
||
- proxy_override=None + инстанс на env → НЕ релончим (golden-parity).
|
||
|
||
camoufox НЕ запускается: _launch_browser мокается заглушкой, которая ставит
|
||
_browsers/_launched_proxy как реальный launch.
|
||
|
||
Запуск (из tradein-mvp/browser/)::
|
||
|
||
python -m pytest test_server_proxy_override.py -q
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import importlib.util
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
_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)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
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, "_launched_proxy", {})
|
||
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||
|
||
|
||
# ── _resolve_proxy_override ──────────────────────────────────────────────────────
|
||
|
||
|
||
def test_resolve_proxy_override_http() -> None:
|
||
body = {"proxy": "http://u:p@pool:8080", "proxy_kind": "http"}
|
||
assert server._resolve_proxy_override(body, "avito") == "http://u:p@pool:8080"
|
||
|
||
|
||
def test_resolve_proxy_override_missing_returns_none() -> None:
|
||
assert server._resolve_proxy_override({"url": "x"}, "avito") is None
|
||
assert server._resolve_proxy_override({"proxy": ""}, "avito") is None
|
||
|
||
|
||
def test_resolve_proxy_override_kind_defaults_http_when_absent() -> None:
|
||
"""proxy без proxy_kind трактуем как http (в пуле kind=http)."""
|
||
assert server._resolve_proxy_override({"proxy": "http://pool:8080"}, "cian") == (
|
||
"http://pool:8080"
|
||
)
|
||
|
||
|
||
def test_resolve_proxy_override_socks5_ignored() -> None:
|
||
"""socks5 camoufox не умеет → None (env-fallback)."""
|
||
body = {"proxy": "socks5://u:p@pool:1080", "proxy_kind": "socks5"}
|
||
assert server._resolve_proxy_override(body, "avito") is None
|
||
|
||
|
||
# ── relaunch policy: _ensure_browser ─────────────────────────────────────────────
|
||
|
||
|
||
def _install_fake_launch(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str | None]]:
|
||
"""Мок _launch_browser: пишет _browsers/_launched_proxy как реальный, пишет calls."""
|
||
calls: list[tuple[str, str | None]] = []
|
||
|
||
async def _fake_launch(provider: str, proxy_override: str | None = None) -> None:
|
||
calls.append((provider, proxy_override))
|
||
server._browsers[provider] = object()
|
||
server._launched_proxy[provider] = (
|
||
proxy_override if proxy_override is not None else server._provider_proxy(provider)
|
||
)
|
||
|
||
monkeypatch.setattr(server, "_launch_browser", _fake_launch)
|
||
return calls
|
||
|
||
|
||
def test_ensure_browser_launches_with_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
calls = _install_fake_launch(monkeypatch)
|
||
|
||
async def _run() -> bool:
|
||
return await server._ensure_browser("avito", proxy_override="http://p1:8080")
|
||
|
||
ok = asyncio.run(_run())
|
||
assert ok is True
|
||
assert calls == [("avito", "http://p1:8080")]
|
||
assert server._launched_proxy["avito"] == "http://p1:8080"
|
||
|
||
|
||
def test_ensure_browser_same_proxy_no_relaunch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
calls = _install_fake_launch(monkeypatch)
|
||
|
||
async def _run() -> None:
|
||
await server._ensure_browser("avito", proxy_override="http://p1:8080")
|
||
await server._ensure_browser("avito", proxy_override="http://p1:8080")
|
||
|
||
asyncio.run(_run())
|
||
# Только один launch — второй вызов с тем же прокси НЕ релончит.
|
||
assert calls == [("avito", "http://p1:8080")]
|
||
|
||
|
||
def test_ensure_browser_changed_proxy_relaunches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
calls = _install_fake_launch(monkeypatch)
|
||
|
||
async def _run() -> None:
|
||
await server._ensure_browser("avito", proxy_override="http://p1:8080")
|
||
await server._ensure_browser("avito", proxy_override="http://p2:8080")
|
||
|
||
asyncio.run(_run())
|
||
assert calls == [("avito", "http://p1:8080"), ("avito", "http://p2:8080")]
|
||
assert server._launched_proxy["avito"] == "http://p2:8080"
|
||
|
||
|
||
def test_ensure_browser_none_override_env_no_relaunch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""proxy_override=None (флаг off) + инстанс на env → НЕ релончим (golden-parity)."""
|
||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
|
||
calls = _install_fake_launch(monkeypatch)
|
||
|
||
async def _run() -> None:
|
||
await server._ensure_browser("avito") # lazy launch на env
|
||
await server._ensure_browser("avito") # второй раз — env тот же
|
||
|
||
asyncio.run(_run())
|
||
assert calls == [("avito", None)]
|
||
assert server._launched_proxy["avito"] == "http://env:8080"
|
||
|
||
|
||
def test_ensure_browser_env_then_pool_relaunches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""env-launched инстанс + пришёл pool-proxy → relaunch (реальная смена)."""
|
||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
|
||
calls = _install_fake_launch(monkeypatch)
|
||
|
||
async def _run() -> None:
|
||
await server._ensure_browser("avito") # env
|
||
await server._ensure_browser("avito", proxy_override="http://pool:8080") # смена
|
||
|
||
asyncio.run(_run())
|
||
assert calls == [("avito", None), ("avito", "http://pool:8080")]
|
||
|
||
|
||
# ── #3187: гео-параметры запуска camoufox ────────────────────────────────────────
|
||
|
||
|
||
def test_launch_leaves_timezone_to_geoip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Зона и координаты — только от geoip по exit-IP, вручную в config не задаются.
|
||
|
||
camoufox 0.5.5 (utils.launch_options): geoip кладёт в config координаты
|
||
безусловно, а timezone — через setdefault, то есть ручной config["timezone"]
|
||
перебивает зону из IP, и координаты с зоной расходятся (узел в Новосибирске
|
||
с заявленной МСК). Ключи 'timezone' и 'geolocation:*' в config — ровно те,
|
||
на которые библиотека пишет LeakWarning (warn_manual_config).
|
||
"""
|
||
import sys
|
||
import types
|
||
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeCamoufox:
|
||
def __init__(self, **kwargs: object) -> None:
|
||
captured.update(kwargs)
|
||
|
||
async def __aenter__(self) -> object:
|
||
return object()
|
||
|
||
fake_mod = types.ModuleType("camoufox.async_api")
|
||
fake_mod.AsyncCamoufox = _FakeCamoufox # type: ignore[attr-defined]
|
||
monkeypatch.setitem(sys.modules, "camoufox", types.ModuleType("camoufox"))
|
||
monkeypatch.setitem(sys.modules, "camoufox.async_api", fake_mod)
|
||
|
||
asyncio.run(server._launch_browser("avito", proxy_override="http://u:p@176.59.137.239:8080"))
|
||
|
||
assert captured["geoip"] is True
|
||
assert captured["proxy"] == {
|
||
"server": "http://176.59.137.239:8080",
|
||
"username": "u",
|
||
"password": "p",
|
||
}
|
||
config = captured.get("config", {})
|
||
assert isinstance(config, dict)
|
||
manual_geo = sorted(k for k in config if k == "timezone" or k.startswith("geolocation:"))
|
||
assert manual_geo == []
|