T9.2: события старта worker.start → nats.start; _connect_s3 — info/warning/error по ответу проверки бакета, клиент остаётся. T9.3: spec без «R-17». T9.4: README и CLAUDE.md описывают уже существующие файлы без отсылок к будущим задачам. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
239 lines
10 KiB
Python
239 lines
10 KiB
Python
"""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 loguru import logger
|
||
|
||
from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT
|
||
from src.core.clients import S3Storage
|
||
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):
|
||
def start(self) -> None:
|
||
events.append("worker.start")
|
||
super().start()
|
||
|
||
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"
|
||
# Старт: воркер готов до подключения (on_connected сразу подписывает worker.on_request).
|
||
# Остановка: воркер дорабатывает очередь, пока NATS ещё жив, затем NATS, затем S3
|
||
assert events == ["worker.start", "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
|
||
|
||
|
||
# --- проверка бакета при старте (_connect_s3) ------------------------------------------------------------------------
|
||
@pytest.fixture
|
||
def log_records() -> Iterator[list[Any]]:
|
||
records: list[Any] = []
|
||
handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG")
|
||
yield records
|
||
logger.remove(handler_id)
|
||
|
||
|
||
class FakeMinio:
|
||
"""Подмена `miniopy_async.Minio`: `bucket_exists` отдаёт заданный ответ или бросает исключение."""
|
||
|
||
answer: bool | Exception = True
|
||
closed = 0
|
||
|
||
def __init__(self, *_args: Any, **_kwargs: Any) -> None:
|
||
pass
|
||
|
||
async def bucket_exists(self, bucket: str) -> bool:
|
||
if isinstance(FakeMinio.answer, Exception):
|
||
raise FakeMinio.answer
|
||
return FakeMinio.answer
|
||
|
||
async def close_session(self) -> None:
|
||
FakeMinio.closed += 1
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("answer", "level", "fragment"),
|
||
[
|
||
pytest.param(True, "INFO", "Бакет проверен", id="bucket-exists"),
|
||
pytest.param(False, "WARNING", "Бакет не найден", id="bucket-missing"),
|
||
pytest.param(ConnectionError("MinIO недоступен"), "ERROR", "Не удалось проверить", id="minio-down"),
|
||
],
|
||
)
|
||
async def test_connect_s3_logs_bucket_check_and_keeps_client(
|
||
monkeypatch: pytest.MonkeyPatch, log_records: list[Any], answer: Any, level: str, fragment: str
|
||
) -> None:
|
||
monkeypatch.setattr(FakeMinio, "answer", answer)
|
||
monkeypatch.setattr(FakeMinio, "closed", 0)
|
||
monkeypatch.setattr(app_module, "Minio", FakeMinio)
|
||
|
||
storage = await app_module._connect_s3()
|
||
|
||
# Клиент остаётся в любом случае: MinIO может подняться позже, воркер прочитает файл без рестарта
|
||
assert isinstance(storage, S3Storage)
|
||
assert storage.bucket == app_module.settings.OPENBAO.MINIO.MINIO_BUCKET
|
||
assert FakeMinio.closed == 0
|
||
messages = [record["message"] for record in log_records if record["level"].name == level]
|
||
assert any(fragment in message for message in messages), messages
|