test: add tests for status WebSocket and scheduler

- test_status.py: 8 tests covering WS auth rejection/acceptance, broadcast_status (dead connection removal, no response_time, no connections), broadcast_scan_update
- test_scheduler.py: 9 tests covering _load_interval variants, _run_status_checks DB update/last_seen/error handling, start/stop lifecycle
- scheduler.py: reinitialize AsyncIOScheduler on each start() to avoid stale event loop across test restarts
This commit is contained in:
Pouzor
2026-03-09 01:32:31 +01:00
parent 20fe4ed9e4
commit 8da39327ab
4 changed files with 343 additions and 1 deletions
+3 -1
View File
@@ -13,7 +13,7 @@ from app.services.status_checker import check_node
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
scheduler: AsyncIOScheduler = AsyncIOScheduler()
async def _run_status_checks() -> None:
@@ -56,6 +56,8 @@ def _load_interval() -> int:
def start_scheduler() -> None:
global scheduler
scheduler = AsyncIOScheduler()
interval = _load_interval()
scheduler.add_job(_run_status_checks, "interval", seconds=interval, id="status_checks")
scheduler.start()
+3
View File
@@ -25,3 +25,6 @@ addopts = "--tb=short -q"
[tool.coverage.run]
source = ["app"]
omit = ["*/migrations/*", "*/tests/*"]
[tool.coverage.report]
skip_empty = true
+189
View File
@@ -0,0 +1,189 @@
"""Tests for background scheduler: _load_interval, _run_status_checks, lifecycle."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.scheduler import _load_interval, _run_status_checks, start_scheduler, stop_scheduler
from app.db.database import Base
from app.db.models import Node
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_node(**kwargs) -> Node:
defaults = dict(
id=str(uuid.uuid4()),
type="server",
label="Test",
status="unknown",
pos_x=0.0,
pos_y=0.0,
)
return Node(**{**defaults, **kwargs})
# ---------------------------------------------------------------------------
# _load_interval
# ---------------------------------------------------------------------------
def test_load_interval_reads_from_config(tmp_path):
"""_load_interval returns the value set in config.yml."""
cfg = tmp_path / "config.yml"
cfg.write_text("status_checker:\n interval_seconds: 30\n")
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.config_path = str(cfg)
assert _load_interval() == 30
def test_load_interval_defaults_to_60_when_key_missing(tmp_path):
"""_load_interval returns 60 when status_checker section is absent."""
cfg = tmp_path / "config.yml"
cfg.write_text("auth:\n username: admin\n")
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.config_path = str(cfg)
assert _load_interval() == 60
def test_load_interval_defaults_to_60_on_missing_file():
"""_load_interval returns 60 when config file does not exist."""
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.config_path = "/nonexistent/path/config.yml"
assert _load_interval() == 60
# ---------------------------------------------------------------------------
# _run_status_checks
# ---------------------------------------------------------------------------
@pytest.fixture
async def mem_db():
"""In-memory SQLite DB with Node table created."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
yield factory
await engine.dispose()
@pytest.mark.asyncio
async def test_run_status_checks_skips_nodes_without_check_method(mem_db):
"""Nodes with no check_method are skipped; check_node is never called."""
async with mem_db() as session:
node = _make_node(check_method=None, ip="10.0.0.1")
session.add(node)
await session.commit()
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_node", new_callable=AsyncMock) as mock_check:
await _run_status_checks()
mock_check.assert_not_called()
@pytest.mark.asyncio
async def test_run_status_checks_updates_node_status(mem_db):
"""check_node result is persisted to the DB and broadcast via WebSocket."""
async with mem_db() as session:
node = _make_node(check_method="ping", ip="10.0.0.1")
session.add(node)
await session.commit()
node_id = node.id
check_result = {"status": "online", "response_time_ms": 5}
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_node", new_callable=AsyncMock, return_value=check_result), \
patch("app.api.routes.status.broadcast_status", new_callable=AsyncMock) as mock_broadcast:
await _run_status_checks()
# Verify DB updated
async with mem_db() as session:
updated = await session.get(Node, node_id)
assert updated is not None
assert updated.status == "online"
assert updated.response_time_ms == 5
# Verify WebSocket broadcast
mock_broadcast.assert_awaited_once()
_, kwargs = mock_broadcast.call_args
assert kwargs["node_id"] == node_id
assert kwargs["status"] == "online"
@pytest.mark.asyncio
async def test_run_status_checks_sets_last_seen_only_when_online(mem_db):
"""last_seen is updated only when status is 'online'."""
async with mem_db() as session:
node = _make_node(check_method="ping", ip="10.0.0.1", last_seen=None)
session.add(node)
await session.commit()
node_id = node.id
check_result = {"status": "offline", "response_time_ms": None}
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_node", new_callable=AsyncMock, return_value=check_result), \
patch("app.api.routes.status.broadcast_status", new_callable=AsyncMock):
await _run_status_checks()
async with mem_db() as session:
updated = await session.get(Node, node_id)
assert updated is not None
assert updated.last_seen is None # not set for offline
@pytest.mark.asyncio
async def test_run_status_checks_handles_check_error_gracefully(mem_db):
"""An exception from check_node is logged and does not abort other nodes."""
async with mem_db() as session:
n1 = _make_node(check_method="ping", ip="10.0.0.1")
n2 = _make_node(check_method="ping", ip="10.0.0.2")
session.add_all([n1, n2])
await session.commit()
call_count = 0
async def flaky_check(method, target, ip):
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("timeout")
return {"status": "online", "response_time_ms": 1}
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_node", side_effect=flaky_check), \
patch("app.api.routes.status.broadcast_status", new_callable=AsyncMock):
await _run_status_checks() # must not raise
assert call_count == 2
# ---------------------------------------------------------------------------
# start_scheduler / stop_scheduler
# ---------------------------------------------------------------------------
def test_start_and_stop_scheduler():
"""Scheduler can be started and stopped without errors."""
mock_sched = MagicMock()
with patch("app.core.scheduler._load_interval", return_value=3600), \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
start_scheduler()
stop_scheduler()
mock_sched.add_job.assert_called_once()
mock_sched.start.assert_called_once()
mock_sched.shutdown.assert_called_once()
def test_start_scheduler_uses_configured_interval():
"""Scheduler registers the status_checks job with the correct interval."""
mock_sched = MagicMock()
with patch("app.core.scheduler._load_interval", return_value=120) as mock_interval, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
start_scheduler()
mock_interval.assert_called_once()
mock_sched.add_job.assert_called_once()
_, kwargs = mock_sched.add_job.call_args
assert kwargs.get("seconds") == 120
mock_sched.start.assert_called_once()
+148
View File
@@ -0,0 +1,148 @@
"""Tests for WebSocket status endpoint and broadcast helpers."""
import json
import pytest
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from app.api.routes.status import _connections, broadcast_scan_update, broadcast_status
from app.main import app
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_token() -> str:
from app.core.security import create_access_token
return create_access_token("admin")
# ---------------------------------------------------------------------------
# WebSocket authentication
# ---------------------------------------------------------------------------
def test_websocket_rejected_without_token():
"""Connection with no token must be closed before being accepted."""
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status"):
pass
def test_websocket_rejected_with_invalid_token():
"""Connection with a garbage token must be closed."""
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status?token=not-a-valid-jwt"):
pass
def test_websocket_accepted_with_valid_token():
"""Connection with a valid JWT must be accepted and kept open."""
token = _make_token()
with TestClient(app) as client, client.websocket_connect(f"/api/v1/status/ws/status?token={token}") as ws:
# Connection is open — we can send a ping and it should not raise
ws.send_text("ping")
# Server keeps the connection open (no disconnect expected)
# ---------------------------------------------------------------------------
# broadcast_status
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_broadcast_status_sends_to_connected_clients():
"""broadcast_status sends a JSON message to all active connections."""
received: list[str] = []
class FakeWS:
async def send_text(self, text: str) -> None:
received.append(text)
fake = FakeWS()
_connections.append(fake)
try:
await broadcast_status(
node_id="node-1",
status="online",
checked_at="2024-01-01T00:00:00",
response_time_ms=42,
)
finally:
_connections.remove(fake)
assert len(received) == 1
msg = json.loads(received[0])
assert msg["type"] == "status"
assert msg["node_id"] == "node-1"
assert msg["status"] == "online"
assert msg["response_time_ms"] == 42
@pytest.mark.asyncio
async def test_broadcast_status_no_response_time():
"""response_time_ms defaults to None."""
received: list[str] = []
class FakeWS:
async def send_text(self, text: str) -> None:
received.append(text)
fake = FakeWS()
_connections.append(fake)
try:
await broadcast_status(node_id="n", status="offline", checked_at="t")
finally:
_connections.remove(fake)
msg = json.loads(received[0])
assert msg["response_time_ms"] is None
@pytest.mark.asyncio
async def test_broadcast_status_removes_dead_connection():
"""A connection that raises on send is removed from _connections."""
class DeadWS:
async def send_text(self, _: str) -> None:
raise RuntimeError("disconnected")
dead = DeadWS()
_connections.append(dead)
initial_len = len(_connections)
await broadcast_status(node_id="n", status="online", checked_at="t")
assert dead not in _connections
assert len(_connections) == initial_len - 1
# ---------------------------------------------------------------------------
# broadcast_scan_update
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_broadcast_scan_update():
"""broadcast_scan_update sends scan_device_found payload."""
received: list[str] = []
class FakeWS:
async def send_text(self, text: str) -> None:
received.append(text)
fake = FakeWS()
_connections.append(fake)
try:
await broadcast_scan_update(run_id="run-42", devices_found=3)
finally:
_connections.remove(fake)
assert len(received) == 1
msg = json.loads(received[0])
assert msg["type"] == "scan_device_found"
assert msg["run_id"] == "run-42"
assert msg["devices_found"] == 3
@pytest.mark.asyncio
async def test_broadcast_no_connections():
"""broadcast_* with no connections must not raise."""
assert len(_connections) == 0
await broadcast_status(node_id="n", status="online", checked_at="t")
await broadcast_scan_update(run_id="r", devices_found=0)