Files
homelable/backend/tests/test_scheduler.py
T
Pouzor ab36ba6f81 feat: import hosts/VMs/LXC from Proxmox VE with optional auto-sync
Add a Proxmox VE importer that reads the /api2/json REST API with a read-only
API token and drops hosts (proxmox), VMs (vm) and LXC containers (lxc) onto the
canvas as typed nodes with run state and hardware specs (vCPU/RAM/disk).

- Backend: proxmox_service (httpx) + proxmox routes (test-connection, import,
  import-pending, config). Two-tier dedupe — merge onto an existing scanned node
  by IP, else synthetic pve-{host}-{vmid} identity. Update-in-place, never
  deletes. Host->guest rendered as a 'virtual' edge via the pending-link flow.
- Security: token is env-only (PROXMOX_TOKEN_*), never written to disk by the
  app, never returned by any endpoint; errors are credential-sanitized.
- Auto-sync: optional scheduled re-import into pending (APScheduler job).
- PendingDevice.properties carries specs through approve (+ migration).
- Frontend: ProxmoxImportModal, sidebar entry, pending inventory source filter,
  Settings auto-sync section, proxmoxApi client.
- Docs: docs/proxmox-import.md, README + FEATURES sections, .env.example keys.
- Tests: backend service/router/scheduler, frontend modal/client/pending.

ha-relevant: maybe
2026-07-05 18:58:12 +02:00

302 lines
11 KiB
Python

"""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