"""test_server_smoke.py — смоук для resilience-логики tradein-browser. Полный браузер (camoufox subprocess) юнит-тестить непрактично, поэтому здесь только смоук: (1) модуль импортируется; (2) /fetch и /login отдают 503 когда браузер не поднят (прокси лежит) — критичный resilience-инвариант, защищающий от crash-storm. camoufox в окружении теста НЕ нужен: launch замокан no-op'ом, имитируя «прокси всё ещё недоступен». Запуск (из 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()) def test_module_imports() -> None: """Смоук: модуль импортируется и экспонирует ключевые хендлеры.""" assert callable(server.fetch_handler) assert callable(server.login_handler) assert callable(server.health_handler) def test_health_reports_browser_not_ready(monkeypatch: pytest.MonkeyPatch) -> None: """/health всегда 200, browser_ready=False когда браузер не поднят.""" monkeypatch.setattr(server, "_browser", None) 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["browser_ready"] is False def test_fetch_returns_503_when_browser_unavailable( monkeypatch: pytest.MonkeyPatch, ) -> None: """/fetch отдаёт 503 (не 500/краш) когда браузер не поднят и launch не удался. Имитирует «прокси лежит»: _browser=None + _try_launch_browser no-op, оставляющий None. Никакого реального camoufox-launch'а / direct-fetch. """ monkeypatch.setattr(server, "_browser", None) monkeypatch.setattr(server, "_fetch_lock", asyncio.Lock()) async def _no_op_launch() -> bool: return False # прокси всё ещё недоступен → браузер остаётся None monkeypatch.setattr(server, "_try_launch_browser", _no_op_launch) request = make_mocked_request( "POST", "/fetch", payload=b'{"url": "https://example.com"}' ) async def _run() -> Any: # make_mocked_request не парсит тело — подменяем .json() на нужный payload. request.json = lambda: _coro({"url": "https://example.com"}) # 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-логирования).""" monkeypatch.setattr(server, "_browser", None) monkeypatch.setattr(server, "_fetch_lock", asyncio.Lock()) async def _no_op_launch() -> bool: return False monkeypatch.setattr(server, "_try_launch_browser", _no_op_launch) login_body = { "url": "https://cian.example/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"] async def _coro(value: Any) -> Any: """Хелпер: оборачивает значение в awaitable для подмены request.json().""" return value