wrolpi/controller/test/test_admin.py
Roland ccc6cf78d3
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
Setting system timezone using Controller.
2026-03-13 15:56:21 -06:00

426 lines
18 KiB
Python

"""
Unit tests for controller.lib.admin module.
"""
from unittest import mock
import pytest
from controller.lib.admin import (
apply_timezone_from_config,
disable_hotspot,
disable_throttle,
enable_hotspot,
enable_throttle,
get_hotspot_status,
get_throttle_status,
get_timezone_status_dict,
reboot_system,
restart_all_services,
set_timezone,
shutdown_system,
HotspotStatus,
GovernorStatus,
)
class TestGetHotspotStatus:
"""Tests for get_hotspot_status function."""
def test_returns_hotspot_status_enum(self):
"""Should return a HotspotStatus enum value."""
result = get_hotspot_status()
assert isinstance(result, HotspotStatus)
def test_returns_unknown_in_docker_mode(self):
"""Should return unknown when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = get_hotspot_status()
assert result == HotspotStatus.unknown
def test_handles_nmcli_not_found(self):
"""Should handle nmcli not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.admin.get_current_ssid", return_value=None):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = get_hotspot_status()
assert result == HotspotStatus.unknown
def test_handles_timeout(self):
"""Should handle subprocess timeout."""
import subprocess
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.admin.get_current_ssid", return_value=None):
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("nmcli", 5)):
result = get_hotspot_status()
assert result == HotspotStatus.unknown
class TestEnableHotspot:
"""Tests for enable_hotspot function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = enable_hotspot()
assert result["success"] is False
assert "Docker" in result.get("error", "")
def test_handles_nmcli_not_found(self):
"""Should handle nmcli not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = enable_hotspot()
assert result["success"] is False
assert "nmcli" in result.get("error", "").lower()
def test_uses_config_values(self):
"""Should use get_config_value for device, ssid, and password."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
# Mock get_config_value to return custom values
def mock_get_config(key, default=None):
config_map = {
'hotspot.device': 'wlan1',
'hotspot.ssid': 'TestSSID',
'hotspot.password': 'testpassword123',
}
return config_map.get(key, default)
with mock.patch("controller.lib.admin.get_config_value", side_effect=mock_get_config):
mock_run = mock.Mock()
mock_run.returncode = 0
with mock.patch("subprocess.run", return_value=mock_run) as mock_subprocess:
enable_hotspot()
# Verify the hotspot was created with config values
calls = mock_subprocess.call_args_list
# Find the hotspot creation call (nmcli device wifi hotspot)
hotspot_call = [c for c in calls if len(c[0][0]) > 2 and 'hotspot' in c[0][0]]
if hotspot_call:
cmd_args = hotspot_call[0][0][0]
assert "wlan1" in cmd_args
assert "TestSSID" in cmd_args
assert "testpassword123" in cmd_args
class TestDisableHotspot:
"""Tests for disable_hotspot function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = disable_hotspot()
assert result["success"] is False
assert "Docker" in result.get("error", "")
def test_handles_nmcli_not_found(self):
"""Should handle nmcli not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = disable_hotspot()
assert result["success"] is False
assert "nmcli" in result.get("error", "").lower()
class TestGetThrottleStatus:
"""Tests for get_throttle_status function."""
def test_returns_governor_status_enum(self):
"""Should return a GovernorStatus enum value."""
result = get_throttle_status()
assert isinstance(result, GovernorStatus)
def test_returns_unknown_in_docker_mode(self):
"""Should return unknown when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = get_throttle_status()
assert result == GovernorStatus.unknown
def test_returns_unknown_when_cpufreq_not_available(self, tmp_path):
"""Should return unknown when cpufreq doesn't exist."""
from pathlib import Path
nonexistent_path = tmp_path / "nonexistent"
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch.object(Path, "exists", return_value=False):
result = get_throttle_status()
assert result == GovernorStatus.unknown
def test_reads_governor_from_sysfs(self, tmp_path):
"""Should read governor from sysfs when available."""
from pathlib import Path
governor_path = tmp_path / "scaling_governor"
governor_path.write_text("ondemand")
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
# This test verifies the function structure - actual sysfs testing
# would require a real Raspberry Pi environment
result = get_throttle_status()
assert isinstance(result, GovernorStatus)
class TestEnableThrottle:
"""Tests for enable_throttle function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = enable_throttle()
assert result["success"] is False
assert "Docker" in result.get("error", "")
class TestDisableThrottle:
"""Tests for disable_throttle function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = disable_throttle()
assert result["success"] is False
assert "Docker" in result.get("error", "")
class TestShutdownSystem:
"""Tests for shutdown_system function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = shutdown_system()
assert result["success"] is False
assert "Docker" in result.get("error", "")
def test_handles_shutdown_not_found(self):
"""Should handle shutdown command not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("shutil.which", return_value=None):
result = shutdown_system()
assert result["success"] is False
assert "not found" in result.get("error", "").lower()
def test_calls_shutdown_command(self):
"""Should call shutdown command with correct args after delay."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("shutil.which", return_value="/sbin/shutdown"):
mock_popen = mock.Mock()
with mock.patch("subprocess.Popen", mock_popen):
# Use delay=0 for testing to avoid waiting
result = shutdown_system(delay=0)
assert result["success"] is True
assert "shutting down" in result["message"].lower()
# Give thread time to execute
import time
time.sleep(0.1)
mock_popen.assert_called_once_with(["shutdown", "-h", "now"])
class TestRebootSystem:
"""Tests for reboot_system function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = reboot_system()
assert result["success"] is False
assert "Docker" in result.get("error", "")
def test_handles_reboot_not_found(self):
"""Should handle reboot command not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("shutil.which", return_value=None):
result = reboot_system()
assert result["success"] is False
assert "not found" in result.get("error", "").lower()
def test_calls_reboot_command(self):
"""Should call reboot command after delay."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("shutil.which", return_value="/sbin/reboot"):
mock_popen = mock.Mock()
with mock.patch("subprocess.Popen", mock_popen):
# Use delay=0 for testing to avoid waiting
result = reboot_system(delay=0)
assert result["success"] is True
assert "rebooting" in result["message"].lower()
# Give thread time to execute
import time
time.sleep(0.1)
mock_popen.assert_called_once_with(["reboot"])
class TestRestartAllServices:
"""Tests for restart_all_services function."""
@pytest.mark.asyncio
async def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = await restart_all_services()
assert result["success"] is False
assert "Docker" in result.get("error", "")
@pytest.mark.asyncio
async def test_restarts_services(self):
"""Should attempt to restart all services."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
# Mock subprocess.run to succeed
mock_result = mock.Mock()
mock_result.returncode = 0
mock_result.stderr = ""
# Mock _restart_self to avoid actual restart
with mock.patch("subprocess.run", return_value=mock_result):
with mock.patch("controller.lib.admin._restart_self"):
with mock.patch("asyncio.get_event_loop") as mock_loop:
mock_loop.return_value.call_later = mock.Mock()
result = await restart_all_services()
assert result["success"] is True
assert "services" in result
# Should include controller as pending
assert result["services"]["wrolpi-controller"]["pending"] is True
class TestGetTimezoneStatus:
"""Tests for get_timezone_status_dict function."""
def test_returns_unavailable_in_docker_mode(self):
"""Should return unavailable when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = get_timezone_status_dict()
assert result["available"] is False
assert result["timezone"] is None
assert "Docker" in result["reason"]
def test_returns_timezone_from_timedatectl(self):
"""Should return the current system timezone."""
mock_result = mock.Mock()
mock_result.returncode = 0
mock_result.stdout = "America/Denver\n"
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", return_value=mock_result):
result = get_timezone_status_dict()
assert result["available"] is True
assert result["timezone"] == "America/Denver"
assert result["reason"] is None
def test_handles_timedatectl_not_found(self):
"""Should handle timedatectl not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = get_timezone_status_dict()
assert result["available"] is False
assert "not found" in result["reason"]
def test_handles_timeout(self):
"""Should handle subprocess timeout."""
import subprocess
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("timedatectl", 5)):
result = get_timezone_status_dict()
assert result["available"] is False
assert "timed out" in result["reason"]
class TestSetTimezone:
"""Tests for set_timezone function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = set_timezone("America/Denver")
assert result["success"] is False
assert "Docker" in result["error"]
def test_sets_timezone_successfully(self):
"""Should set timezone via timedatectl."""
mock_result = mock.Mock()
mock_result.returncode = 0
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", return_value=mock_result) as mock_run:
result = set_timezone("America/Denver")
assert result["success"] is True
assert result["timezone"] == "America/Denver"
mock_run.assert_called_once_with(
["timedatectl", "set-timezone", "America/Denver"],
capture_output=True,
text=True,
timeout=10,
)
def test_handles_invalid_timezone(self):
"""Should handle timedatectl rejecting an invalid timezone."""
mock_result = mock.Mock()
mock_result.returncode = 1
mock_result.stderr = "Failed to set time zone: Invalid time zone"
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", return_value=mock_result):
result = set_timezone("Invalid/Timezone")
assert result["success"] is False
assert "Invalid" in result["error"]
def test_handles_empty_timezone(self):
"""Should reject empty timezone strings."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
result = set_timezone("")
assert result["success"] is False
assert "empty" in result["error"].lower()
def test_handles_timedatectl_not_found(self):
"""Should handle timedatectl not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = set_timezone("America/Denver")
assert result["success"] is False
assert "not found" in result["error"]
class TestApplyTimezoneFromConfig:
"""Tests for apply_timezone_from_config function."""
def test_skips_in_docker_mode(self):
"""Should do nothing in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
with mock.patch("controller.lib.admin.set_timezone") as mock_set:
apply_timezone_from_config()
mock_set.assert_not_called()
def test_applies_timezone_from_config(self, tmp_path):
"""Should read timezone from wrolpi.yaml and apply it."""
config_dir = tmp_path / 'config'
config_dir.mkdir()
config_file = config_dir / 'wrolpi.yaml'
config_file.write_text("timezone: America/New_York\n")
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.admin.get_media_directory", return_value=tmp_path):
with mock.patch("controller.lib.admin.set_timezone", return_value={"success": True}) as mock_set:
apply_timezone_from_config()
mock_set.assert_called_once_with("America/New_York")
def test_skips_when_no_timezone_in_config(self, tmp_path):
"""Should do nothing when timezone is not set in config."""
config_dir = tmp_path / 'config'
config_dir.mkdir()
config_file = config_dir / 'wrolpi.yaml'
config_file.write_text("download_wait: 60\n")
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.admin.get_media_directory", return_value=tmp_path):
with mock.patch("controller.lib.admin.set_timezone") as mock_set:
apply_timezone_from_config()
mock_set.assert_not_called()
def test_skips_when_config_file_missing(self, tmp_path):
"""Should do nothing when wrolpi.yaml doesn't exist."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.admin.get_media_directory", return_value=tmp_path):
with mock.patch("controller.lib.admin.set_timezone") as mock_set:
apply_timezone_from_config()
mock_set.assert_not_called()