"""Состояние для `/healthcheck` (`src.core.health`): поля `nats` и `hotfolder` (spec «Старт в режиме локальных папок», «Горячая папка недоступна», «Соединение с NATS без JetStream»; D-8; T8.4 — проверка не зависает, T2.3 — сбой SMB).""" import threading from pathlib import Path import pytest from src.core.health import HealthProvider from src.gateway.hotfolder import AnswerFile, LocalHotFolder pytestmark = pytest.mark.usefixtures("log_lines") class Connection: def __init__(self, connected: bool) -> None: self.is_connected = connected class CountingHotFolder: """`healthy()` считает вызовы; может зависнуть до события или бросить исключение.""" def __init__(self, result: bool = True) -> None: self.result = result self.calls = 0 self.release: threading.Event | None = None self.error: BaseException | None = None def put(self, name: str, data: bytes) -> None: raise NotImplementedError def poll(self, job_id: str) -> list[AnswerFile] | None: raise NotImplementedError def healthy(self) -> bool: self.calls += 1 if self.release is not None: self.release.wait(5) if self.error is not None: raise self.error return self.result class Clock: def __init__(self) -> None: self.now = 1000.0 def __call__(self) -> float: return self.now async def test_local_root_exists_is_ok(tmp_path: Path) -> None: health = HealthProvider(nats_enabled=False, hotfolder=LocalHotFolder(tmp_path)) assert await health.snapshot() == {"nats": "disabled", "hotfolder": "ok"} async def test_missing_root_is_down(tmp_path: Path) -> None: health = HealthProvider(nats_enabled=False, hotfolder=LocalHotFolder(tmp_path / "missing")) assert await health.hotfolder_status() == "down" async def test_no_hotfolder_is_down() -> None: assert await HealthProvider(nats_enabled=False, hotfolder=None).hotfolder_status() == "down" @pytest.mark.parametrize( ("enabled", "connection", "expected"), [ (False, None, "disabled"), (False, Connection(True), "disabled"), (True, None, "down"), (True, Connection(False), "down"), (True, Connection(True), "ok"), ], ) def test_nats_status(enabled: bool, connection: Connection | None, expected: str) -> None: health = HealthProvider(nats_enabled=enabled, hotfolder=None) health.nats = connection assert health.nats_status() == expected async def test_hotfolder_result_is_cached_for_ttl() -> None: hotfolder = CountingHotFolder(result=True) clock = Clock() health = HealthProvider(nats_enabled=False, hotfolder=hotfolder, cache_ttl=5, clock=clock) assert await health.hotfolder_status() == "ok" hotfolder.result = False clock.now += 4.9 assert await health.hotfolder_status() == "ok" assert hotfolder.calls == 1 clock.now += 0.2 assert await health.hotfolder_status() == "down" assert hotfolder.calls == 2 async def test_hanging_check_is_down_within_timeout_and_not_repeated() -> None: hotfolder = CountingHotFolder() hotfolder.release = threading.Event() clock = Clock() health = HealthProvider(nats_enabled=False, hotfolder=hotfolder, check_timeout=0.05, cache_ttl=5, clock=clock) try: assert await health.hotfolder_status() == "down" clock.now += 10 # кэш истёк, но прежняя проверка ещё висит — новый поток не запускается assert await health.hotfolder_status() == "down" assert hotfolder.calls == 1 finally: hotfolder.release.set() async def test_check_raising_is_down() -> None: hotfolder = CountingHotFolder() hotfolder.error = OSError("smb") health = HealthProvider(nats_enabled=False, hotfolder=hotfolder) assert await health.hotfolder_status() == "down" async def test_snapshot_with_connected_nats(tmp_path: Path) -> None: health = HealthProvider(nats_enabled=True, hotfolder=LocalHotFolder(tmp_path)) health.nats = Connection(True) assert await health.snapshot() == {"nats": "ok", "hotfolder": "ok"}