diff --git a/backend/app/core/scheduler.py b/backend/app/core/scheduler.py index dfe2376..630c12b 100644 --- a/backend/app/core/scheduler.py +++ b/backend/app/core/scheduler.py @@ -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() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8822d8f..6d7c62e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -25,3 +25,6 @@ addopts = "--tb=short -q" [tool.coverage.run] source = ["app"] omit = ["*/migrations/*", "*/tests/*"] + +[tool.coverage.report] +skip_empty = true diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py new file mode 100644 index 0000000..fb4457a --- /dev/null +++ b/backend/tests/test_scheduler.py @@ -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() diff --git a/backend/tests/test_status.py b/backend/tests/test_status.py new file mode 100644 index 0000000..aa0d148 --- /dev/null +++ b/backend/tests/test_status.py @@ -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)