"""Приложение: `/healthcheck` (`uptime`, `version`, `nats`, `hotfolder`), lifespan (SMB-сессия, S3, NATS в фоне, воркер). NATS и S3 не нужны: в тестах lifespan `NATS_ENABLED=false` либо методы `NatsGateway` подменены.""" import asyncio import subprocess import sys 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 from src.normalizer import OcrNormalizer 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", "subjects"} assert body["version"] == get_settings().VERSION assert body["subjects"] == {"image_request": "files.img_uploaded", "result": "files.tabular_parsed"} datetime.fromisoformat(body["uptime"]) async def test_healthcheck_subjects_from_env_without_jwt(local_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT", "test.files.img_uploaded") monkeypatch.setenv("OPENBAO__NATS__NATS_RESULT_SUBJECT", "test.files.tabular_parsed") monkeypatch.setenv("OPENBAO__NATS__NATS_JWT", "secret-jwt-value") get_settings.cache_clear() app = create_app(get_settings()) async with _client(app) as client: response = await client.get("/healthcheck") assert response.json()["subjects"] == { "image_request": "test.files.img_uploaded", "result": "test.files.tabular_parsed", } assert "secret-jwt-value" not in response.text async def test_healthcheck_nats_down_hides_jwt(local_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: """NATS включён, но не подключён (`down`): значения JWT в теле `/healthcheck` нет.""" monkeypatch.setenv("NATS_ENABLED", "true") monkeypatch.setenv("OPENBAO__NATS__NATS_JWT", "secret-jwt-value") never = asyncio.Event() async def fake_connect(self: NatsGateway) -> bool: await never.wait() return True async def fake_close(self: NatsGateway) -> None: return None monkeypatch.setattr(NatsGateway, "connect", fake_connect) monkeypatch.setattr(NatsGateway, "close", fake_close) get_settings.cache_clear() app = create_app(get_settings()) async with app.router.lifespan_context(app), _client(app) as client: response = await client.get("/healthcheck") assert response.json()["nats"] == "down" assert "secret-jwt-value" not in response.text async def test_startup_logs_nats_subjects( local_env: Path, monkeypatch: pytest.MonkeyPatch, log_lines: list[str] ) -> None: monkeypatch.setenv("OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT", "test.files.img_uploaded") monkeypatch.setenv("OPENBAO__NATS__NATS_RESULT_SUBJECT", "test.files.tabular_parsed") get_settings.cache_clear() app = create_app(get_settings()) async with app.router.lifespan_context(app): pass line = next(line for line in log_lines if "Subject'ы NATS" in line) assert "test.files.img_uploaded" in line and "test.files.tabular_parsed" in line 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": "внутренняя ошибка сервера"} def test_create_app_uses_package_rules() -> None: app = create_app(get_settings().model_copy(update={"SCAN_HOTFOLDER_PATH": "."})) expected = (Path(__file__).resolve().parents[2] / "arm_statements" / "VERSION").read_text(encoding="utf-8").strip() assert app.state.normalizer.statements_version == expected def test_create_app_uses_ocr_normalizer() -> None: app = create_app(get_settings().model_copy(update={"SCAN_HOTFOLDER_PATH": "."})) assert isinstance(app.state.normalizer, OcrNormalizer) # --- Логгер: значения локальных переменных не попадают в лог (#18) ---------------------------------------------------- _LOG_EXCEPTION_SCRIPT = """ import importlib import os import sys from loguru import logger sys.path.insert(0, os.getcwd()) importlib.import_module("src.app") def fail(): secret = "local-secret-value" raise ValueError("boom" if secret else "") try: fail() except ValueError: logger.exception("ошибка") logger.complete() """ def test_app_import_logs_exceptions_without_local_values(tmp_path: Path) -> None: """Импорт приложения настраивает loguru через setup_logger (diagnose=False): трейсбек есть, значений локальных переменных в нём нет. Отдельный процесс — тесты перенастраивают обработчики loguru, здесь нужен чистый импорт. Скрипт — файлом: без исходника (`python -c`) loguru не аннотирует строки и тест прошёл бы и с diagnose=True.""" script = tmp_path / "log_exception.py" script.write_text(_LOG_EXCEPTION_SCRIPT) result = subprocess.run( [sys.executable, str(script)], cwd=Path(__file__).resolve().parents[2], capture_output=True, text=True, timeout=60, check=True, ) output = result.stdout + result.stderr assert "ValueError: boom" in output assert "local-secret-value" not in output