arm-core/api/tests/test_app.py
Andrey Epifancev 9b81fa8e37 feat: inbox recognition.result — ack processing, идемпотентный приём результата, s3_object результата, ResultMapper/NoopMapper
handle_message(msg, *, session_factory, mapper, now): разбор конверта по контракту v1.0, одна короткая транзакция
с SELECT … FOR UPDATE (ждёт блокировку публикатора/вахтёра); ack published → processing; результат — дедуп по
result_msg_id, финальные статусы (R-22) не трогаются, регистрация s3_object (results), слияние метрик с сохранением
republish_count. Подписка app.py получает handler с фабрикой сессий и NoopMapper; шаблонный core/nats_callback.py
удалён (R-3). audit_event не пишется (R-7).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 00:46:48 +04:00

303 lines
11 KiB
Python
Raw 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.

"""Быстрые тесты каркаса приложения: lifespan, /healthcheck, префикс API, клиент S3 (без БД и 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
from src.services import recognition_exchange
# 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.fixture(autouse=True)
def exchange_loops(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Циклы публикатора и вахтёра подменены ожиданием: быстрые тесты lifespan не ходят в БД."""
started: list[str] = []
async def publisher(session_factory: Any, nats: Any, **kwargs: Any) -> None:
started.append("publisher")
await asyncio.Event().wait()
async def watchdog(session_factory: Any, **kwargs: Any) -> None:
started.append("watchdog")
await asyncio.Event().wait()
monkeypatch.setattr(recognition_exchange, "run_publisher_loop", publisher)
monkeypatch.setattr(recognition_exchange, "run_watchdog_loop", watchdog)
return started
@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()
async def test_result_subscription_uses_inbox_handler_with_dependencies():
"""Подписка `recognition.result` получает `handle_message` с фабрикой сессий приложения и `NoopMapper`."""
subscribed: list[Any] = []
class _FakeConnection:
async def subscribe_results(self, handler: Any) -> None:
subscribed.append(handler)
await app_module._subscribe_results(_FakeConnection())
(handler,) = subscribed
assert handler.func is recognition_exchange.handle_message
assert handler.args == ()
assert set(handler.keywords) == {"session_factory", "mapper"}
assert handler.keywords["session_factory"] is app_module.async_session_maker
assert isinstance(handler.keywords["mapper"], recognition_exchange.NoopMapper)
def test_create_nats_subscribes_results_on_connect(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True)
nats = app_module._create_nats()
assert nats is not None
assert nats._on_connected is app_module._subscribe_results
@pytest.mark.parametrize("secure", [True, False])
async def test_connect_s3_passes_secure_flag(monkeypatch: pytest.MonkeyPatch, secure: bool):
created: dict[str, Any] = {}
class _FakeMinio:
def __init__(self, endpoint: str, **kwargs: Any) -> None:
created.update(kwargs, endpoint=endpoint)
async def bucket_exists(self, bucket: str) -> bool:
return True
monkeypatch.setattr(app_module, "Minio", _FakeMinio)
monkeypatch.setattr(app_module.settings.OPENBAO.MINIO, "MINIO_SECURE", secure)
storage = await app_module._connect_s3()
assert storage is not None
assert created["secure"] is secure
class _RecordingNats(_FakeNats):
"""Запоминает, остановлены ли фоновые задачи обмена к моменту `stop()` соединения NATS."""
def __init__(self) -> None:
super().__init__()
self.exchange_done_at_stop: bool | None = None
async def stop(self) -> None:
exchange = app_module.app.state.exchange
self.exchange_done_at_stop = all(task.done() for task in exchange.tasks)
await super().stop()
@pytest.mark.usefixtures("_no_smb")
async def test_lifespan_starts_exchange_tasks_and_stops_them_before_nats(
monkeypatch: pytest.MonkeyPatch, exchange_loops: list[str]
):
"""NATS_ENABLED=true: публикатор и вахтёр — две фоновые задачи; остановка — до `nats.stop()`."""
async def unavailable() -> None:
return None
fake_nats = _RecordingNats()
monkeypatch.setattr(app_module, "_connect_s3", unavailable)
monkeypatch.setattr(app_module, "_create_nats", lambda: fake_nats)
async with app_module.lifespan(app_module.app):
exchange = app_module.app.state.exchange
assert exchange is not None
assert len(exchange.tasks) == 2 # noqa: PLR2004 - публикатор и вахтёр
await asyncio.sleep(0)
assert sorted(exchange_loops) == ["publisher", "watchdog"]
assert not any(task.done() for task in exchange.tasks)
assert fake_nats.exchange_done_at_stop is True
assert fake_nats.stopped
@pytest.mark.usefixtures("_no_smb")
async def test_lifespan_without_nats_starts_no_exchange_tasks(
monkeypatch: pytest.MonkeyPatch, exchange_loops: list[str]
):
"""NATS_ENABLED=false (`_create_nats` → None): фоновые задачи обмена не запускаются."""
async def unavailable() -> None:
return None
monkeypatch.setattr(app_module, "_connect_s3", unavailable)
monkeypatch.setattr(app_module.settings, "NATS_ENABLED", False)
tasks_before = asyncio.all_tasks()
async with app_module.lifespan(app_module.app):
assert app_module.app.state.exchange is None
await asyncio.sleep(0)
assert exchange_loops == []
assert asyncio.all_tasks() - tasks_before == set()