"""test_server_smoke.py — смоук для resilience-логики tradein-browser. Полный браузер (camoufox subprocess) юнит-тестить непрактично, поэтому здесь только смоук: (1) модуль импортируется; (2) /fetch и /login отдают 503 когда браузер поставщика не поднят (прокси лежит) — критичный resilience-инвариант, защищающий от crash-storm. camoufox в окружении теста НЕ нужен: launch замокан no-op'ом, имитируя «прокси всё ещё недоступен». Per-provider модель (#1793): состояние keyed по provider (avito/cian/yandex/ generic), доступ к инстансу сериализован _locks[provider]; разные поставщики работают параллельно. Конкурентность проверяется в test_server.py. Запуск (из tradein-mvp/browser/):: python -m pytest test_server_smoke.py -q aiohttp.test_utils.make_mocked_request не поднимает реальный сервер/сокет — хендлер вызывается напрямую, без event-loop-сервера. """ 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]: """Достаёт JSON-тело из aiohttp Response (body — bytes после json_response).""" 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, "_locks_guard", asyncio.Lock()) def test_module_imports() -> None: """Смоук: модуль импортируется и экспонирует ключевые хендлеры.""" assert callable(server.fetch_handler) assert callable(server.login_handler) assert callable(server.health_handler) def test_health_reports_browsers_not_ready() -> None: """/health всегда 200, browsers[]=False когда инстансы не подняты.""" request = make_mocked_request("GET", "/health") response = asyncio.run(server.health_handler(request)) assert response.status == 200 body = _json_body(response) assert body["status"] == "ok" assert body["browsers"] == {"avito": False, "cian": False, "yandex": False, "generic": False} def test_health_reports_partial_readiness() -> None: """/health отражает per-provider readiness: один поднят, остальные нет.""" server._browsers["avito"] = object() request = make_mocked_request("GET", "/health") response = asyncio.run(server.health_handler(request)) body = _json_body(response) assert body["browsers"]["avito"] is True assert body["browsers"]["cian"] is False def test_fetch_returns_503_when_browser_unavailable( monkeypatch: pytest.MonkeyPatch, ) -> None: """/fetch отдаёт 503 (не 500/краш) когда инстанс поставщика не поднят. Имитирует «прокси лежит»: _ensure_browser no-op, оставляющий инстанс None. Никакого реального camoufox-launch'а / direct-fetch. """ async def _no_ensure(provider: str) -> bool: return False # прокси всё ещё недоступен → браузер остаётся None monkeypatch.setattr(server, "_ensure_browser", _no_ensure) request = make_mocked_request("POST", "/fetch") async def _run() -> Any: request.json = lambda: _coro({"url": "https://www.avito.ru/x"}) # type: ignore[method-assign] return await server.fetch_handler(request) response = asyncio.run(_run()) assert response.status == 503 body = _json_body(response) assert "browser unavailable" in body["error"] def test_login_returns_503_when_browser_unavailable( monkeypatch: pytest.MonkeyPatch, ) -> None: """/login тоже отдаёт 503 когда инстанс не поднят (без credential-логирования).""" async def _no_ensure(provider: str) -> bool: return False monkeypatch.setattr(server, "_ensure_browser", _no_ensure) login_body = { "url": "https://www.cian.ru/login", "email": "u@example.com", "password": "secret", "email_selector": "#email", "password_selector": "#password", "submit_selector": "#submit", } request = make_mocked_request("POST", "/login") async def _run() -> Any: request.json = lambda: _coro(login_body) # type: ignore[method-assign] return await server.login_handler(request) response = asyncio.run(_run()) assert response.status == 503 body = _json_body(response) assert "browser unavailable" in body["error"] # ── per-provider state isolation ───────────────────────────────────────────────── class _FakePage: """Поддельная page: считает open/close и отмечает пик параллелизма.""" def __init__(self, tracker: "_Tracker") -> None: self._tracker = tracker async def route(self, pattern: str, handler: Any) -> None: return None async def goto(self, url: str, **kwargs: Any) -> None: self._tracker.active += 1 self._tracker.peak = max(self._tracker.peak, self._tracker.active) await asyncio.sleep(0.02) self._tracker.active -= 1 async def wait_for_timeout(self, ms: int) -> None: return None async def content(self) -> str: return "ok" async def close(self) -> None: self._tracker.closed += 1 class _FakeBrowser: def __init__(self, tracker: "_Tracker") -> None: self._tracker = tracker async def new_page(self) -> _FakePage: self._tracker.opened += 1 return _FakePage(self._tracker) class _Tracker: def __init__(self) -> None: self.opened = 0 self.closed = 0 self.active = 0 self.peak = 0 def _make_fetch_request(url: str = "https://www.avito.ru/x") -> Any: request = make_mocked_request("POST", "/fetch") request.json = lambda: _coro({"url": url}) # type: ignore[method-assign] return request def test_fetch_opens_and_closes_own_page(monkeypatch: pytest.MonkeyPatch) -> None: """Один /fetch открывает ровно одну собственную страницу и закрывает её.""" tracker = _Tracker() server._browsers["avito"] = _FakeBrowser(tracker) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) async def _ensure(provider: str) -> bool: return True monkeypatch.setattr(server, "_ensure_browser", _ensure) response = asyncio.run(server.fetch_handler(_make_fetch_request())) assert response.status == 200 assert _json_body(response)["html"] == "ok" assert tracker.opened == 1 assert tracker.closed == 1 # ── _relaunch resets per-provider page counter (recycle) ───────────────────────── def test_fetch_recycles_after_threshold(monkeypatch: pytest.MonkeyPatch) -> None: """Достижение BROWSER_RECYCLE_PAGES → _relaunch_browser + сброс счётчика.""" tracker = _Tracker() server._browsers["avito"] = _FakeBrowser(tracker) server._page_counters["avito"] = 0 monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 1) relaunched = {"n": 0} async def _fake_relaunch(provider: str) -> None: relaunched["n"] += 1 server._page_counters[provider] = 0 async def _ensure(provider: str) -> bool: return True monkeypatch.setattr(server, "_relaunch_browser", _fake_relaunch) monkeypatch.setattr(server, "_ensure_browser", _ensure) asyncio.run(server.fetch_handler(_make_fetch_request())) assert relaunched["n"] == 1, "recycle должен сработать на пороге" assert server._page_counters["avito"] == 0 # ── _login_once closes page on CancelledError (BaseException leak fix) ──────────── class _CancellingLoginPage: """Page для /login, которая бросает CancelledError внутри логина.""" def __init__(self, tracker: "_Tracker") -> None: self._tracker = tracker self.url = "https://www.cian.ru/login" async def goto(self, url: str, **kwargs: Any) -> None: return None async def wait_for_timeout(self, ms: int) -> None: return None async def fill(self, selector: str, value: str, **kwargs: Any) -> None: raise asyncio.CancelledError() async def close(self) -> None: self._tracker.closed += 1 class _CancellingLoginBrowser: def __init__(self, tracker: "_Tracker") -> None: self._tracker = tracker async def new_page(self) -> _CancellingLoginPage: self._tracker.opened += 1 return _CancellingLoginPage(self._tracker) def test_login_closes_page_on_cancelled_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """_login_once закрывает страницу даже при CancelledError (finally, не except).""" tracker = _Tracker() server._browsers["cian"] = _CancellingLoginBrowser(tracker) params = { "url": "https://www.cian.ru/login", "email": "u@example.com", "password": "secret", "email_selector": "#email", "password_selector": "#password", "submit_selector": "#submit", } with pytest.raises(asyncio.CancelledError): asyncio.run(server._login_once("cian", params)) assert tracker.opened == 1 assert tracker.closed == 1, "страница должна закрыться в finally при CancelledError" # ── _fetch_once: origin-goto ТОЛЬКО когда передан (DomClick anti-bot, #2429-ish) ── # Зеркалит _fetch_json_once (#1917): когда origin передан, камуфокс сначала заходит # на origin (same-site якорь, напр. SERP), затем на целевой url — органическая # навигация вместо холодного goto. origin=None (дефолт, все providers кроме # domclick detail) должен остаться РОВНО одним goto(url), без регрессии. class _FakeCookieContext: """Поддельный BrowserContext: фиксирует add_cookies-вызовы (порядок + payload).""" def __init__(self) -> None: self.add_cookies_calls: list[list[dict]] = [] async def add_cookies(self, cookies: list[dict]) -> None: self.add_cookies_calls.append(cookies) class _OriginTrackingPage: """Поддельная page: фиксирует ВСЕ goto-URL по порядку вызова + cookie-инъекции.""" def __init__(self) -> None: self.goto_urls: list[str] = [] self.closed = 0 self.context = _FakeCookieContext() async def route(self, pattern: str, handler: Any) -> None: return None async def goto(self, url: str, **kwargs: Any) -> None: self.goto_urls.append(url) async def wait_for_timeout(self, ms: int) -> None: return None async def content(self) -> str: return "ok" async def close(self) -> None: self.closed += 1 class _OriginTrackingBrowser: def __init__(self, page: _OriginTrackingPage) -> None: self._page = page async def new_page(self) -> _OriginTrackingPage: return self._page def test_fetch_once_navigates_origin_then_url_when_provided( monkeypatch: pytest.MonkeyPatch, ) -> None: """origin передан → ДВА goto по порядку: origin первым, затем url.""" page = _OriginTrackingPage() server._browsers["avito"] = _OriginTrackingBrowser(page) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) html = asyncio.run( server._fetch_once( "avito", "https://www.avito.ru/card/1", origin="https://www.avito.ru/serp" ) ) assert html == "ok" assert page.goto_urls == ["https://www.avito.ru/serp", "https://www.avito.ru/card/1"] assert page.closed == 1 def test_fetch_once_single_goto_when_origin_none(monkeypatch: pytest.MonkeyPatch) -> None: """origin не передан (дефолт, avito/cian/yandex) → РОВНО один goto(url), без regressии.""" page = _OriginTrackingPage() server._browsers["avito"] = _OriginTrackingBrowser(page) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) assert html == "ok" assert page.goto_urls == ["https://www.avito.ru/card/1"] # ── _fetch_once: cookies-инъекция ТОЛЬКО когда передана (DomClick QRATOR-bypass) ── # Провайдер-агностично: domain НЕ захардкожен ".domclick.ru" — выводится из # urlparse(url).hostname (с ведущей точкой) прямо в _fetch_once, поэтому механизм # годится для будущих caller'ов (Cian/Avito/Yandex), не только DomClick. # cookies=None (дефолт, все providers сейчас) должен остаться без add_cookies-вызова. def test_fetch_once_injects_cookies_before_goto_with_derived_domain( monkeypatch: pytest.MonkeyPatch, ) -> None: """cookies передан → context.add_cookies() ДО goto(url), domain = "." + hostname.""" page = _OriginTrackingPage() server._browsers["cian"] = _OriginTrackingBrowser(page) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) html = asyncio.run( server._fetch_once( "cian", "https://ekaterinburg.domclick.ru/card/sale__flat__1", cookies={"CAS_ID": "12345", "qrator_jsid2": "abc"}, ) ) assert html == "ok" assert len(page.context.add_cookies_calls) == 1 injected = page.context.add_cookies_calls[0] assert { "name": "CAS_ID", "value": "12345", "domain": ".ekaterinburg.domclick.ru", "path": "/", } in injected assert { "name": "qrator_jsid2", "value": "abc", "domain": ".ekaterinburg.domclick.ru", "path": "/", } in injected # Инъекция ДО навигации на целевой url — единственный goto остаётся ровно url. assert page.goto_urls == ["https://ekaterinburg.domclick.ru/card/sale__flat__1"] def test_fetch_once_no_cookie_injection_when_cookies_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """cookies=None (дефолт) → add_cookies НЕ вызывается, поведение не меняется.""" page = _OriginTrackingPage() server._browsers["avito"] = _OriginTrackingBrowser(page) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) assert page.context.add_cookies_calls == [] def test_fetch_once_cookie_domain_derivation_is_generic_not_domclick_hardcoded( monkeypatch: pytest.MonkeyPatch, ) -> None: """Domain derivation работает для ЛЮБОГО хоста, не только domclick.ru. Доказывает, что механизм провайдер-агностичен: hardcode ".domclick.ru" НЕ используется — тот же код с avito.ru url выводит ".www.avito.ru". """ page = _OriginTrackingPage() server._browsers["avito"] = _OriginTrackingBrowser(page) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) asyncio.run( server._fetch_once( "avito", "https://www.avito.ru/items/1", cookies={"sessionid": "xyz"} ) ) injected = page.context.add_cookies_calls[0] assert injected == [ {"name": "sessionid", "value": "xyz", "domain": ".www.avito.ru", "path": "/"} ] async def _coro(value: Any) -> Any: """Хелпер: оборачивает значение в awaitable для подмены request.json().""" return value