"""Быстрые тесты каркаса приложения: lifespan, /healthcheck, префикс API (без БД и NATS). Аутентификация и supervisor NATS — tests/test_nats_core.py (подключение переехало в src/core/nats.py, рулинг R-3). """ import asyncio import importlib from http import HTTPStatus from types import SimpleNamespace from typing import Any import httpx import pytest from src.api.router import router from src.core.nats import NatsConnection # src/__init__.py реэкспортирует объект app под именем модуля, поэтому модуль берём через importlib app_module = importlib.import_module("src.app") @pytest.fixture async def client(): # Без lifespan: app.state.nats не установлен — healthcheck обязан это пережить transport = httpx.ASGITransport(app=app_module.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: yield c async def test_healthcheck_nats_off_when_disabled(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(app_module.settings, "NATS_ENABLED", False) response = await client.get("/healthcheck") assert response.status_code == HTTPStatus.OK body = response.json() assert body["nats"] == "off" assert {"uptime", "version"} <= set(body) async def test_healthcheck_nats_down_when_enabled_without_connection( client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch ): monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) response = await client.get("/healthcheck") assert response.status_code == HTTPStatus.OK assert response.json()["nats"] == "down" @pytest.mark.parametrize(("connected", "expected"), [(True, "ok"), (False, "down")]) async def test_healthcheck_nats_follows_connection_state( client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, connected: bool, expected: str ): monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) monkeypatch.setattr(app_module.app.state, "nats", SimpleNamespace(is_connected=connected), raising=False) response = await client.get("/healthcheck") assert response.status_code == HTTPStatus.OK assert response.json()["nats"] == expected def test_router_prefix(): assert router.prefix == "/api/v1" class _FakeNats: def __init__(self) -> None: self.started = False self.stopped = False self.is_connected = False async def start(self) -> None: self.started = True async def stop(self) -> None: self.stopped = True class _FakeS3Client: def __init__(self) -> None: self.session_closed = False async def close_session(self) -> None: self.session_closed = True @pytest.fixture def _no_smb(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(app_module.smbclient, "register_session", lambda *args, **kwargs: None) monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", lambda: None) @pytest.mark.usefixtures("_no_smb") async def test_lifespan_starts_when_s3_and_nats_unavailable(monkeypatch: pytest.MonkeyPatch): async def unavailable() -> None: return None monkeypatch.setattr(app_module, "_connect_s3", unavailable) monkeypatch.setattr(app_module, "_create_nats", lambda: None) reached_yield = False async with app_module.lifespan(app_module.app): reached_yield = True assert app_module.app.state.storage is None assert app_module.app.state.nats is None assert reached_yield @pytest.mark.usefixtures("_no_smb") async def test_lifespan_starts_and_stops_nats_and_closes_s3(monkeypatch: pytest.MonkeyPatch): fake_nats = _FakeNats() s3_client = _FakeS3Client() storage = SimpleNamespace(client=s3_client) async def connect_s3() -> SimpleNamespace: return storage monkeypatch.setattr(app_module, "_connect_s3", connect_s3) monkeypatch.setattr(app_module, "_create_nats", lambda: fake_nats) async with app_module.lifespan(app_module.app): assert app_module.app.state.storage is storage assert app_module.app.state.nats is fake_nats assert fake_nats.started assert not fake_nats.stopped assert not s3_client.session_closed assert fake_nats.stopped assert s3_client.session_closed class _NeverConnectingClient: """Клиент nats-py, который никогда не подключается: connect висит (как nats-py без сервера).""" is_connected = False is_closed = False async def connect(self, servers: str, **options: Any) -> None: await asyncio.Event().wait() async def close(self) -> None: self.is_closed = True @pytest.mark.usefixtures("_no_smb") async def test_lifespan_does_not_wait_for_nats_and_healthcheck_is_down(monkeypatch: pytest.MonkeyPatch): """Регрессия: недоступный NATS не задерживает старт; до первого подключения healthcheck показывает down.""" async def unavailable() -> None: return None nats = NatsConnection(app_module.settings.OPENBAO.NATS, client_factory=_NeverConnectingClient) monkeypatch.setattr(app_module, "_connect_s3", unavailable) monkeypatch.setattr(app_module, "_create_nats", lambda: nats) monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) async with app_module.lifespan(app_module.app): transport = httpx.ASGITransport(app=app_module.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: response = await c.get("/healthcheck") assert response.json()["nats"] == "down" assert not nats.is_connected def test_create_nats_disabled_returns_none(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(app_module.settings, "NATS_ENABLED", False) assert app_module._create_nats() is None def test_create_nats_enabled_returns_connection_for_results(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) nats = app_module._create_nats() assert isinstance(nats, NatsConnection) assert (nats.result_subject, nats.queue) == ("recognition.result", "arm-core") assert not nats.is_connected # создание не подключает: подключение — в start()