"""Tests for background scheduler: _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 ( _run_proxmox_sync, _run_service_checks, _run_status_checks, reschedule_proxmox_sync, set_proxmox_sync_enabled, set_service_checks_enabled, 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}) # --------------------------------------------------------------------------- # _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_scheduler_uses_settings_interval(): """Scheduler registers the job with the interval from settings.""" mock_sched = MagicMock() with patch("app.core.scheduler.settings") as mock_settings, \ patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched): mock_settings.status_checker_interval = 45 mock_settings.service_check_enabled = False mock_settings.proxmox_sync_enabled = False start_scheduler() _, kwargs = mock_sched.add_job.call_args assert kwargs["seconds"] == 45 def test_start_and_stop_scheduler(): """Scheduler can be started and stopped without errors.""" mock_sched = MagicMock() with patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched), \ patch("app.core.scheduler.settings") as mock_settings: mock_settings.status_checker_interval = 60 mock_settings.service_check_enabled = False mock_settings.proxmox_sync_enabled = False start_scheduler() stop_scheduler() mock_sched.add_job.assert_called_once() mock_sched.start.assert_called_once() mock_sched.shutdown.assert_called_once() # --------------------------------------------------------------------------- # Service checks # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_run_service_checks_disabled_does_nothing(mem_db): async with mem_db() as session: session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}])) await session.commit() with patch("app.core.scheduler.settings") as mock_settings, \ patch("app.core.scheduler.AsyncSessionLocal", mem_db), \ patch("app.services.status_checker.check_services", new_callable=AsyncMock) as mock_cs: mock_settings.service_check_enabled = False await _run_service_checks() mock_cs.assert_not_called() @pytest.mark.asyncio async def test_run_service_checks_broadcasts_per_node(mem_db): async with mem_db() as session: node = _make_node( ip="10.0.0.5", services=[{"port": 80, "protocol": "tcp", "service_name": "http"}], ) session.add(node) await session.commit() node_id = node.id statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}] with patch("app.core.scheduler.settings") as mock_settings, \ patch("app.core.scheduler.AsyncSessionLocal", mem_db), \ patch("app.core.scheduler.check_services", new_callable=AsyncMock, return_value=statuses), \ patch("app.api.routes.status.broadcast_service_status", new_callable=AsyncMock) as mock_bcast: mock_settings.service_check_enabled = True await _run_service_checks() mock_bcast.assert_awaited_once() _, kwargs = mock_bcast.call_args assert kwargs["node_id"] == node_id assert kwargs["services"] == statuses @pytest.mark.asyncio async def test_run_service_checks_skips_nodes_without_services(mem_db): async with mem_db() as session: session.add(_make_node(ip="10.0.0.6", services=[])) await session.commit() with patch("app.core.scheduler.settings") as mock_settings, \ patch("app.core.scheduler.AsyncSessionLocal", mem_db), \ patch("app.core.scheduler.check_services", new_callable=AsyncMock) as mock_cs: mock_settings.service_check_enabled = True await _run_service_checks() mock_cs.assert_not_called() def test_set_service_checks_enabled_adds_and_removes_job(): mock_sched = MagicMock() mock_sched.running = True with patch("app.core.scheduler.scheduler", mock_sched), \ patch("app.core.scheduler.settings") as mock_settings: mock_settings.service_check_interval = 300 # Enable: no existing job -> add mock_sched.get_job.return_value = None set_service_checks_enabled(True) mock_sched.add_job.assert_called_once() # Disable: existing job -> remove mock_sched.get_job.return_value = MagicMock() set_service_checks_enabled(False) mock_sched.remove_job.assert_called_once_with("service_checks") def test_start_scheduler_adds_service_job_when_enabled(): mock_sched = MagicMock() with patch("app.core.scheduler.settings") as mock_settings, \ patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched): mock_settings.status_checker_interval = 60 mock_settings.service_check_enabled = True mock_settings.service_check_interval = 300 mock_settings.proxmox_sync_enabled = False start_scheduler() job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list] assert "status_checks" in job_ids assert "service_checks" in job_ids # --------------------------------------------------------------------------- # Proxmox auto-sync job # --------------------------------------------------------------------------- def test_set_proxmox_sync_enabled_adds_and_removes_job(): mock_sched = MagicMock() mock_sched.running = True with patch("app.core.scheduler.scheduler", mock_sched), \ patch("app.core.scheduler.settings") as mock_settings: mock_settings.proxmox_sync_interval = 3600 mock_sched.get_job.return_value = None set_proxmox_sync_enabled(True) mock_sched.add_job.assert_called_once() mock_sched.get_job.return_value = MagicMock() set_proxmox_sync_enabled(False) mock_sched.remove_job.assert_called_once_with("proxmox_sync") def test_reschedule_proxmox_sync_rejects_short_interval(): with pytest.raises(ValueError): reschedule_proxmox_sync(60) @pytest.mark.asyncio async def test_run_proxmox_sync_skips_when_disabled(): with patch("app.core.scheduler.settings") as mock_settings: mock_settings.proxmox_sync_enabled = False # Must return before importing/fetching anything. await _run_proxmox_sync() @pytest.mark.asyncio async def test_run_proxmox_sync_skips_when_no_token(): with patch("app.core.scheduler.settings") as mock_settings: mock_settings.proxmox_sync_enabled = True mock_settings.proxmox_host = "pve" mock_settings.proxmox_token_id = "" mock_settings.proxmox_token_secret = "" await _run_proxmox_sync() # no exception, no fetch