wrolpi/controller/test/test_status_worker.py
Roland 2806673e25
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
Consolidating controller tests. Fixing controller test cleanup which slowed down tests significantly.
2026-05-15 21:10:30 -06:00

142 lines
5.6 KiB
Python

"""
Tests for the status worker background task and aggregated stats endpoint.
"""
from datetime import datetime
from unittest import mock
import pytest
class TestCollectAllStatus:
"""Tests for collect_all_status function."""
@pytest.mark.asyncio
async def test_collects_all_stats_and_iso_timestamp(self):
"""Should collect every status type and include a parseable ISO timestamp."""
from controller.lib.status_worker import collect_all_status
status = await collect_all_status()
for key in (
"cpu_stats", "memory_stats", "load_stats", "drives_stats",
"nic_bandwidth_stats", "power_stats", "last_status",
):
assert key in status
datetime.fromisoformat(status["last_status"])
@pytest.mark.asyncio
@pytest.mark.parametrize("collector,result_key,failure_value", [
("get_cpu_status", "cpu_stats", None),
("get_memory_status", "memory_stats", None),
("get_drive_status", "drives_stats", []),
("get_network_status", "nic_bandwidth_stats", {}),
])
async def test_individual_collector_failure(self, collector, result_key, failure_value):
"""A single collector raising should leave the failed key at its fallback value
and let the other collectors still populate the result."""
from controller.lib.status_worker import collect_all_status
with mock.patch(
f"controller.lib.status_worker.{collector}",
side_effect=Exception(f"{collector} error"),
):
status = await collect_all_status()
assert status[result_key] == failure_value
# At least one other collector should still have produced real data.
other_keys = [k for k in ("cpu_stats", "memory_stats") if k != result_key]
assert any(status[k] is not None for k in other_keys)
class TestGetAdaptiveSleep:
"""Tests for adaptive sleep calculation."""
def test_returns_base_sleep_when_load_is_low(self):
"""Should return base sleep when load is below CPU count."""
from controller.lib.status_worker import get_adaptive_sleep
with mock.patch("multiprocessing.cpu_count", return_value=4):
load_stats = {"minute_1": "0.5"}
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
assert sleep == 5.0
def test_returns_base_sleep_when_load_equals_cpu_count(self):
"""Should return base sleep when load equals CPU count."""
from controller.lib.status_worker import get_adaptive_sleep
with mock.patch("multiprocessing.cpu_count", return_value=4):
load_stats = {"minute_1": "4.0"}
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
assert sleep == 5.0
def test_increases_sleep_when_load_exceeds_cpu_count(self):
"""Should increase sleep when load exceeds CPU count."""
from controller.lib.status_worker import get_adaptive_sleep
with mock.patch("multiprocessing.cpu_count", return_value=4):
load_stats = {"minute_1": "8.0"} # 2x CPU count
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
# Expected: (5.0 * 8.0) / 4 = 10.0
assert sleep == 10.0
def test_handles_none_load_stats(self):
"""Should return base sleep when load_stats is None."""
from controller.lib.status_worker import get_adaptive_sleep
sleep = get_adaptive_sleep(None, base_sleep=5.0)
assert sleep == 5.0
def test_handles_empty_load_stats(self):
"""Should return base sleep when load_stats is empty dict."""
from controller.lib.status_worker import get_adaptive_sleep
sleep = get_adaptive_sleep({}, base_sleep=5.0)
assert sleep == 5.0
def test_handles_invalid_load_value(self):
"""Should return base sleep when load value is invalid."""
from controller.lib.status_worker import get_adaptive_sleep
load_stats = {"minute_1": "invalid"}
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
assert sleep == 5.0
def test_handles_missing_minute_1_key(self):
"""Should return base sleep when minute_1 key is missing."""
from controller.lib.status_worker import get_adaptive_sleep
load_stats = {"minute_5": "2.0"}
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
assert sleep == 5.0
class TestAggregatedStatsEndpoint:
"""Tests for the /api/stats aggregated endpoint."""
def test_aggregated_stats_payload(self, test_client):
"""Endpoint should return 200 with every status key and the expected sub-shapes."""
response = test_client.get("/api/stats")
assert response.status_code == 200
data = response.json()
for key in (
"cpu_stats", "memory_stats", "load_stats", "drives_stats",
"nic_bandwidth_stats", "power_stats", "last_status",
):
assert key in data
# last_status is an ISO timestamp.
datetime.fromisoformat(data["last_status"])
# Sub-field shape spot-checks.
for field in ("percent", "cores"):
assert field in data["cpu_stats"]
for field in ("total", "used", "free"):
assert field in data["memory_stats"]
for field in ("minute_1", "minute_5", "minute_15"):
assert field in data["load_stats"]
assert isinstance(data["drives_stats"], list)
assert isinstance(data["nic_bandwidth_stats"], dict)
def test_aggregated_stats_trailing_slash(self, test_client):
"""Aggregated stats should work with trailing slash."""
response = test_client.get("/api/stats/")
assert response.status_code == 200