"""Приложение: `/healthcheck` (`uptime`, `version`, `nats`, `hotfolder`), lifespan (SMB-сессия, S3, NATS в фоне, воркер). NATS и S3 не нужны: в тестах lifespan `NATS_ENABLED=false` либо методы `NatsGateway` подменены.""" import asyncio from datetime import datetime from pathlib import Path from typing import Any from unittest.mock import MagicMock import httpx import pytest import smbclient from src.app import create_app from src.core.config import get_settings from src.core.nats import NatsGateway from src.core.worker import Worker pytestmark = pytest.mark.usefixtures("log_lines") def _client(app) -> httpx.AsyncClient: return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") @pytest.fixture def local_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Режим local с существующим корнем и без NATS: lifespan не ходит в сеть.""" monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "local") monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(tmp_path)) monkeypatch.setenv("NATS_ENABLED", "false") return tmp_path async def eventually(predicate, timeout: float = 2.0) -> None: loop = asyncio.get_running_loop() deadline = loop.time() + timeout while not predicate(): assert loop.time() < deadline, "условие не выполнилось вовремя" await asyncio.sleep(0.005) async def test_healthcheck_without_lifespan_has_all_fields(local_env: Path) -> None: app = create_app(get_settings()) # lifespan не запускается: подключений к NATS/S3 нет async with _client(app) as client: response = await client.get("/healthcheck") assert response.status_code == 200 body = response.json() assert set(body) == {"uptime", "version", "nats", "hotfolder"} assert body["version"] == get_settings().VERSION datetime.fromisoformat(body["uptime"]) async def test_start_in_local_mode(local_env: Path) -> None: """Spec «Старт в режиме локальных папок»: 200 с uptime, version, nats, hotfolder = ok; OPENBAO__SMB__* не нужны.""" app = create_app(get_settings()) async with app.router.lifespan_context(app), _client(app) as client: response = await client.get("/healthcheck") assert response.status_code == 200 body = response.json() assert body["hotfolder"] == "ok" assert body["nats"] == "disabled" assert body["version"] == get_settings().VERSION async def test_missing_hotfolder_root_is_down(local_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(local_env / "missing")) app = create_app(get_settings()) async with app.router.lifespan_context(app), _client(app) as client: assert (await client.get("/healthcheck")).json()["hotfolder"] == "down" async def test_lifespan_starts_and_stops_worker(local_env: Path) -> None: app = create_app(get_settings()) async with app.router.lifespan_context(app): worker = app.state.worker assert isinstance(worker, Worker) assert isinstance(app.state.nats, NatsGateway) # после остановки воркер запросов не принимает await worker.on_request(type("Msg", (), {"data": b"{}"})()) assert worker.active_jobs == 0 async def test_lifespan_does_not_register_smb_session_in_local_mode(local_env: Path, monkeypatch) -> None: register = MagicMock() monkeypatch.setattr(smbclient, "register_session", register) app = create_app(get_settings()) async with app.router.lifespan_context(app), _client(app) as client: assert (await client.get("/healthcheck")).status_code == 200 register.assert_not_called() def _smb_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "smb") monkeypatch.setenv("OPENBAO__SMB__SMB_HOST", "smb.example") monkeypatch.setenv("OPENBAO__SMB__SMB_PATH", "share") monkeypatch.setenv("OPENBAO__SMB__SMB_LOGIN", "user") monkeypatch.setenv("OPENBAO__SMB__SMB_PASSWORD", "secret") monkeypatch.setenv("NATS_ENABLED", "false") async def test_lifespan_registers_smb_session_in_smb_mode(monkeypatch) -> None: _smb_env(monkeypatch) register = MagicMock() reset = MagicMock() monkeypatch.setattr(smbclient, "register_session", register) monkeypatch.setattr(smbclient, "reset_connection_cache", reset) monkeypatch.setattr(smbclient, "listdir", MagicMock(return_value=["Input", "Output"])) app = create_app(get_settings()) async with app.router.lifespan_context(app), _client(app) as client: response = await client.get("/healthcheck") assert response.status_code == 200 assert response.json()["hotfolder"] == "ok" register.assert_called_once_with("smb.example", "user", "secret") reset.assert_called_once() async def test_smb_session_failure_shows_hotfolder_down(monkeypatch, log_lines: list[str]) -> None: """T2.3: регистрация SMB-сессии при старте не удалась (шара недоступна) — `hotfolder = down`, сервис жив.""" _smb_env(monkeypatch) monkeypatch.setattr(smbclient, "register_session", MagicMock(side_effect=OSError("сервер недоступен"))) monkeypatch.setattr(smbclient, "listdir", MagicMock(side_effect=OSError("сервер недоступен"))) app = create_app(get_settings()) async with app.router.lifespan_context(app), _client(app) as client: response = await client.get("/healthcheck") assert response.status_code == 200 assert response.json()["hotfolder"] == "down" assert any("SMB" in line for line in log_lines if line.startswith("ERROR|")) async def test_nats_connects_in_background_and_subscribes_worker(local_env: Path, monkeypatch) -> None: monkeypatch.setenv("NATS_ENABLED", "true") subscribed: list[Any] = [] connected = asyncio.Event() async def fake_connect(self: NatsGateway) -> bool: await connected.wait() # подключение «висит», а HTTP уже отвечает return True async def fake_subscribe(self: NatsGateway, cb: Any, queue: str = "scan-gateway") -> None: subscribed.append(cb) async def fake_close(self: NatsGateway) -> None: return None monkeypatch.setattr(NatsGateway, "connect", fake_connect) monkeypatch.setattr(NatsGateway, "subscribe_requests", fake_subscribe) monkeypatch.setattr(NatsGateway, "close", fake_close) app = create_app(get_settings()) async with app.router.lifespan_context(app), _client(app) as client: assert (await client.get("/healthcheck")).json()["nats"] == "down" connected.set() await eventually(lambda: bool(subscribed)) assert subscribed == [app.state.worker.on_request] async def test_nats_connect_hanging_is_cancelled_on_shutdown(local_env: Path, monkeypatch) -> None: monkeypatch.setenv("NATS_ENABLED", "true") cancelled = asyncio.Event() async def fake_connect(self: NatsGateway) -> bool: try: await asyncio.Event().wait() except asyncio.CancelledError: cancelled.set() raise return True async def fake_close(self: NatsGateway) -> None: return None monkeypatch.setattr(NatsGateway, "connect", fake_connect) monkeypatch.setattr(NatsGateway, "close", fake_close) app = create_app(get_settings()) async with app.router.lifespan_context(app): await asyncio.sleep(0.01) assert cancelled.is_set() async def test_nats_config_error_is_loud_and_shows_down(local_env: Path, monkeypatch, log_lines: list[str]) -> None: """Неполные учётные данные NATS (`NatsConfigError`): лог error с трассировкой, `nats = down`, HTTP жив.""" monkeypatch.setenv("NATS_ENABLED", "true") monkeypatch.setenv("OPENBAO__NATS__NATS_SEED", "SUAINCOMPLETESEED") monkeypatch.setenv("OPENBAO__NATS__NATS_JWT", "") app = create_app(get_settings()) async with app.router.lifespan_context(app), _client(app) as client: await eventually(lambda: any(line.startswith("ERROR|") and "NATS" in line for line in log_lines)) response = await client.get("/healthcheck") assert response.status_code == 200 assert response.json()["nats"] == "down" async def test_unhandled_error_has_json_500(local_env: Path) -> None: app = create_app(get_settings()) @app.get("/boom") async def boom(): raise RuntimeError("x") transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/boom") assert response.status_code == 500 assert response.json() == {"detail": "внутренняя ошибка сервера"}