src/core/health.py: HealthProvider — nats ok/down/disabled по is_connected; hotfolder — healthy() в потоке под wait_for (2 с) с кэшем 5 с, зависшая проба не запускается повторно (T8.4). Сбой регистрации SMB-сессии виден естественно: SmbHotFolder.healthy() обращается к шаре с теми же учётными данными (T2.3). app.py: в lifespan S3Storage, NatsGateway, Worker(NullNormalizer); подключение к NATS и подписка — фоновой задачей (connect ждёт сервер), её сбой — logger.exception и nats=down без падения HTTP; остановка отменяет подключение, останавливает воркер, закрывает NATS и S3. Объекты lifespan — в app.state. test_app.py: тесты lifespan переведены на NATS_ENABLED=false и tmp-корень (иначе фоновое подключение шло бы в compose NATS), проверка полей healthcheck расширена nats/hotfolder. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
121 lines
4.2 KiB
Python
121 lines
4.2 KiB
Python
"""Состояние для `/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 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) -> bytes | 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"}
|