Andrey Epifancev a7862c1843 feat: запуск воркера в lifespan при NATS_ENABLED и поле nats = ok | down | off в /healthcheck
Подписка — через on_connected NatsConnection; остановка: воркер (дорабатывает очередь) → NATS → S3. Клиент S3
остаётся и при неудачной проверке бакета, отсутствие бакета — warning (T2.1). Тест lifespan и healthcheck с фейками
(T8.7); формулировки nats в README, .env.example и spec приведены к R-17.

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

180 lines
7.6 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.

"""Unit-тесты lifespan и `/healthcheck` приложения без NATS и MinIO (design D-6, R-17; carry-over T8.7).
`NatsConnection`, `_connect_s3` и `RecognitionWorker` модуля `src.app` подменяются фейками: проверяется, что при
`NATS_ENABLED=true` воркер запускается и подписывается через `on_connected`, порядок остановки
(воркер → NATS → S3) и значения `nats = ok | down | off`.
"""
import importlib
import json
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import httpx
import pytest
from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT
from src.core.worker import RecognitionWorker
EXAMPLES_DIR = Path(__file__).resolve().parents[2] / "contracts" / "examples"
REQUEST = json.loads((EXAMPLES_DIR / "request-tabular.json").read_text(encoding="utf-8"))
JOB_ID = REQUEST["job_id"]
app_module = importlib.import_module("src.app") # модуль, а не объект `app`, который реэкспортирует пакет `src`
@asynccontextmanager
async def running_app(module: Any) -> AsyncIterator[httpx.AsyncClient]:
"""Lifespan приложения + HTTP-клиент поверх ASGI (без сети)."""
application = module.app
async with application.router.lifespan_context(application):
transport = httpx.ASGITransport(app=application)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
class FakeStorage:
def __init__(self, events: list[str]) -> None:
self.events = events
self.client = SimpleNamespace(close_session=self._close)
self.objects = {(REQUEST["s3"]["bucket"], REQUEST["s3"]["key"]): b"xlsx"}
async def _close(self) -> None:
self.events.append("s3.close")
async def get_bytes(self, bucket: str, key: str) -> bytes:
return self.objects[(bucket, key)]
async def put_json(self, bucket: str, key: str, obj: Any) -> None:
raise AssertionError("NullParser не пишет файл результата")
class FakeNatsConnection:
"""Повторяет интерфейс `NatsConnection`, нужный lifespan и воркеру."""
instances: list["FakeNatsConnection"] = []
connect_on_start = True
events: list[str] = [] # общий журнал порядка запуска и остановки (задаётся фикстурой)
def __init__(
self,
settings: Any,
*,
on_connected: Callable[["FakeNatsConnection"], Awaitable[None]] | None = None,
publish_retries: int = 3,
) -> None:
self.settings = settings
self.on_connected = on_connected
self.publish_retries = publish_retries
self.is_connected = False
self.handlers: list[tuple[str, Callable[[Any], Awaitable[None]]]] = []
self.published: list[tuple[str, dict[str, Any], str]] = []
FakeNatsConnection.instances.append(self)
async def start(self) -> None:
self.events.append("nats.start")
if self.connect_on_start:
self.is_connected = True
if self.on_connected is not None:
await self.on_connected(self)
async def stop(self) -> None:
self.events.append("nats.stop")
self.is_connected = False
async def subscribe_requests(self, cb: Callable[[Any], Awaitable[None]]) -> None:
self.handlers.append(("recognition.tabular.request", cb))
async def publish(self, subject: str, payload: bytes, msg_id: str) -> None:
self.published.append((subject, json.loads(payload), msg_id))
@pytest.fixture
def events() -> list[str]:
return []
@pytest.fixture
def patched_app(monkeypatch: pytest.MonkeyPatch, events: list[str]) -> Iterator[Any]:
"""`src.app` с фейковыми S3, NATS и записывающим порядок остановки воркером."""
FakeNatsConnection.instances = []
FakeNatsConnection.connect_on_start = True
FakeNatsConnection.events = events
storage = FakeStorage(events)
async def fake_connect_s3() -> FakeStorage:
return storage
class RecordingWorker(RecognitionWorker):
async def stop(self, timeout: float = 10.0) -> None:
events.append("worker.stop")
await super().stop(timeout)
monkeypatch.setattr(app_module, "_connect_s3", fake_connect_s3)
monkeypatch.setattr(app_module, "NatsConnection", FakeNatsConnection)
monkeypatch.setattr(app_module, "RecognitionWorker", RecordingWorker)
yield app_module
async def test_nats_disabled_starts_nothing_and_healthcheck_reports_off(
patched_app: Any, monkeypatch: pytest.MonkeyPatch, events: list[str]
) -> None:
monkeypatch.setattr(patched_app.settings, "NATS_ENABLED", False)
async with running_app(patched_app) as client:
response = await client.get("/healthcheck")
assert patched_app.app.state.worker is None
assert patched_app.app.state.nats is None
assert response.status_code == 200 # noqa: PLR2004 - HTTP 200
body = response.json()
assert set(body) == {"uptime", "version", "nats"}
assert body["nats"] == "off"
assert body["version"] == patched_app.settings.VERSION
assert FakeNatsConnection.instances == []
assert events == ["s3.close"]
async def test_nats_enabled_subscribes_worker_and_reports_ok(
patched_app: Any, monkeypatch: pytest.MonkeyPatch, events: list[str]
) -> None:
monkeypatch.setattr(patched_app.settings, "NATS_ENABLED", True)
async with running_app(patched_app) as client:
assert (await client.get("/healthcheck")).json()["nats"] == "ok"
(connection,) = FakeNatsConnection.instances
assert connection.publish_retries == patched_app.settings.PUBLISH_RETRIES
assert connection.handlers, "воркер должен подписаться через on_connected"
worker = patched_app.app.state.worker
assert isinstance(worker, RecognitionWorker)
# Запрос, доставленный подписке, проходит через воркер: ack и результат NOT_IMPLEMENTED в recognition.result
_subject, handler = connection.handlers[0]
message = SimpleNamespace(
data=json.dumps(REQUEST).encode(), headers={NATS_MSG_ID_HEADER: f"{JOB_ID}:1"}, subject=_subject
)
await handler(message)
await worker.join()
assert [(subject, msg_id) for subject, _body, msg_id in connection.published] == [
(RESULT_SUBJECT, f"ack:{JOB_ID}:1"),
(RESULT_SUBJECT, f"result:{JOB_ID}:1"),
]
assert connection.published[1][1]["error"]["code"] == "NOT_IMPLEMENTED"
assert connection.published[1][1]["service"]["name"] == "tabular-parser"
# Остановка: воркер дорабатывает очередь, пока NATS ещё жив, затем NATS, затем S3
assert events == ["nats.start", "worker.stop", "nats.stop", "s3.close"]
async def test_nats_enabled_but_not_connected_reports_down(patched_app: Any, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(patched_app.settings, "NATS_ENABLED", True)
FakeNatsConnection.connect_on_start = False
async with running_app(patched_app) as client:
assert (await client.get("/healthcheck")).json()["nats"] == "down"
(connection,) = FakeNatsConnection.instances
assert connection.handlers == [] # подписка — только после подключения
assert patched_app.app.state.worker is not None