arm-core/api/tests/test_app.py
Andrey Epifancev 4e5e1a7b5b feat: src/core/nats.py — фоновое подключение к core NATS, publish с Nats-Msg-Id, подписка recognition.result в queue group arm-core
- NatsConnection: supervisor первого подключения в фоне (старт приложения не ждёт NATS), nats-py
  переподключается сам (max_reconnect_attempts=-1, reconnect_time_wait=2) и восстанавливает подписки;
  экспоненциальный backoff 1→30 с, если connect завершился исключением; логирование ошибок без шума
  (первая ошибка серии — warning, дальше не чаще раза в минуту).
- publish(subject, payload, msg_id): заголовок Nats-Msg-Id + flush; нет соединения или нет подтверждения —
  NatsPublishError. subscribe_results(handler): queue group arm-core, исключения обработчика логируются.
- build_nats_auth перенесён из app.py; ошибка учётных данных — NatsConfigError, категория config_error,
  без повторов (T4.1). Первое подключение повторяется (T4.2): прежний await connect в lifespan при
  недоступном NATS висел бесконечно (nats-py с max_reconnect_attempts=-1 повторяет первое подключение сам).
- app.py: подключение переехало в core/nats.py (R-3), lifespan запускает/останавливает NatsConnection,
  подписка — заглушка services/recognition_exchange.handle_message (полная обработка — задача 24).
- Тесты: юнит (поддельный клиент, инжектируемый sleep, отсутствие JetStream в src/) и интеграционные
  против NATS без JetStream (заголовок, queue group без дублей, недоступный сервер).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 23:47:39 +04:00

181 lines
6.3 KiB
Python

"""Быстрые тесты каркаса приложения: 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()