gendesign/tradein-mvp/browser/test_server_proxy_override.py
bot-backend c871f804cf Сайдкар: таймзона браузера берётся из IP узла, а не жёстко МСК (#3187)
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>
2026-09-17 12:49:37 +05:00

194 lines
8.4 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_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 == []