"""Tests for the WiFi network management module (src.system.network). Uses mocking to avoid requiring actual WiFi hardware. Run with: pytest tests/test_network.py -v """ from __future__ import annotations import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest # Ensure src is on sys.path SRC = Path(__file__).resolve().parent.parent / "src" if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) from src.system.network import ( wifi_scan, wifi_status, wifi_saved_networks, wifi_connect, wifi_disconnect, wifi_forget, hotspot_status, hotspot_enable, hotspot_disable, get_hotspot_password, _has_nmcli, ) # ── Fixtures ───────────────────────────────────────────────────────────────── @pytest.fixture def mock_subprocess(): """Patch subprocess.run globally for all tests in this module.""" with patch("src.system.network.subprocess.run") as mock: yield mock @pytest.fixture def mock_hostapd_conf(tmp_path): """Create a mock /etc/hostapd/hostapd.conf for hotspot tests.""" conf = tmp_path / "hostapd.conf" conf.write_text("ssid=Pi-Pedal\nchannel=6\n") return conf # ── _has_nmcli() tests ──────────────────────────────────────────────────────── def test_has_nmcli_true(mock_subprocess): """When 'which nmcli' succeeds, _has_nmcli returns True.""" mock_subprocess.return_value = MagicMock(returncode=0) assert _has_nmcli() is True def test_has_nmcli_false(mock_subprocess): """When 'which nmcli' fails, _has_nmcli returns False.""" mock_subprocess.side_effect = FileNotFoundError("no nmcli") assert _has_nmcli() is False # ── wifi_scan() tests ───────────────────────────────────────────────────────── def test_wifi_scan_nmcli_networks(mock_subprocess): """wifi_scan returns parsed networks from nmcli.""" mock_subprocess.return_value = MagicMock( stdout="Net1:75:WPA2\nNet2:50:WPA2\nNet3:90:Open\n", returncode=0, ) networks = wifi_scan() assert len(networks) == 3 assert networks[0]["ssid"] == "Net3" # sorted by signal desc assert networks[0]["signal"] == 90 assert networks[0]["encryption"] == "Open" def test_wifi_scan_nmcli_empty(mock_subprocess): """wifi_scan returns empty list when no networks found.""" mock_subprocess.return_value = MagicMock(stdout="", returncode=0) networks = wifi_scan() assert networks == [] def test_wifi_scan_nmcli_fallback_iwlist(mock_subprocess): """wifi_scan falls back to iwlist when nmcli fails.""" # First call (nmcli) fails mock_subprocess.side_effect = [ FileNotFoundError("no nmcli"), # _has_nmcli check MagicMock(stdout="", returncode=0), # First nmcli scan attempt ] # Actually, let's patch _has_nmcli to make this clearer with patch("src.system.network._has_nmcli", return_value=False): with patch("src.system.network._iwlist_scan", return_value=[ {"ssid": "TestNet", "signal": 60, "encryption": "WPA2"} ]) as mock_iw: networks = wifi_scan() assert len(networks) == 1 assert networks[0]["ssid"] == "TestNet" assert networks[0]["signal"] == 60 mock_iw.assert_called_once() def test_wifi_scan_handles_exception(mock_subprocess): """wifi_scan returns empty list on unexpected error.""" with patch("src.system.network._has_nmcli", return_value=True): mock_subprocess.side_effect = RuntimeError("Unexpected") networks = wifi_scan() assert networks == [] # ── wifi_status() tests ─────────────────────────────────────────────────────── def test_wifi_status_connected(mock_subprocess): """wifi_status returns connected state with SSID and IP.""" mock_subprocess.side_effect = [ MagicMock(stdout="wlan0:wifi:connected:HomeNet\neth0:ethernet:connected:eth0\n", returncode=0), MagicMock(stdout="GENERAL.CONNECTION:HomeNet\nIP4.ADDRESS:192.168.1.100/24\n", returncode=0), MagicMock(stdout="HomeNet:85\nOtherNet:30\n", returncode=0), ] with patch("src.system.network._has_nmcli", return_value=True): status = wifi_status() assert status["connected"] is True assert status["ssid"] == "HomeNet" assert status["ip"] == "192.168.1.100" assert status["signal"] == 85 assert status["device"] == "wlan0" def test_wifi_status_not_connected(mock_subprocess): """wifi_status returns disconnected when no WiFi connection.""" mock_subprocess.side_effect = [ MagicMock(stdout="wlan0:wifi:disconnected:\neth0:ethernet:connected:eth0\n", returncode=0), ] status = wifi_status() assert status["connected"] is False assert status["ssid"] is None def test_wifi_status_no_wifi_device(mock_subprocess): """wifi_status returns disconnected when no WiFi device exists.""" mock_subprocess.return_value = MagicMock( stdout="eth0:ethernet:connected:eth0\n", returncode=0, ) status = wifi_status() assert status["connected"] is False # ── wifi_connect() tests ────────────────────────────────────────────────────── def test_wifi_connect_requires_params(): """wifi_connect returns error when SSID or password missing.""" result = wifi_connect("", "password") assert result["ok"] is False assert "SSID" in result["error"] result = wifi_connect("MyNet", "") assert result["ok"] is False assert "Password" in result["error"] def test_wifi_connect_new_network(mock_subprocess): """wifi_connect calls nmcli to connect to a new network.""" # Saved networks (nmcli connection show) returns empty for new # Has nmcli → True from first call check with patch("src.system.network._has_nmcli", return_value=True): mock_subprocess.side_effect = [ # _nmcli_saved - empty list MagicMock(stdout="", returncode=0), # _nmcli_connect - success MagicMock(stdout="", returncode=0), ] result = wifi_connect("NewNet", "secret123") assert result["ok"] is True assert mock_subprocess.call_count >= 2 def test_wifi_connect_existing_network(mock_subprocess): """wifi_connect activates an already-saved network.""" with patch("src.system.network._has_nmcli", return_value=True): mock_subprocess.side_effect = [ # _nmcli_saved - existing network MagicMock(stdout="NewNet:abc-123:wireless\nOldNet:def-456:wireless\n", returncode=0), # _nmcli_connect - connection up MagicMock(stdout="", returncode=0), ] result = wifi_connect("NewNet", "secret123") assert result["ok"] is True # ── wifi_disconnect() tests ─────────────────────────────────────────────────── def test_wifi_disconnect(mock_subprocess): """wifi_disconnect returns ok.""" with patch("src.system.network._has_nmcli", return_value=True): mock_subprocess.side_effect = [ # _nmcli_status MagicMock(stdout="wlan0:wifi:connected:HomeNet\n", returncode=0), MagicMock(stdout="GENERAL.CONNECTION:HomeNet\nIP4.ADDRESS:192.168.1.100/24\n", returncode=0), MagicMock(stdout="HomeNet:80\n", returncode=0), # _nmcli_disconnect MagicMock(stdout="", returncode=0), ] result = wifi_disconnect() assert result["ok"] is True # ── wifi_forget() tests ────────────────────────────────────────────────────── def test_wifi_forget_network(mock_subprocess): """wifi_forget deletes a saved network.""" with patch("src.system.network._has_nmcli", return_value=True): mock_subprocess.side_effect = [ # _nmcli_saved MagicMock(stdout="MyNet:abc-123:wireless\nOther:def-456:wireless\n", returncode=0), # _nmcli_forget - nmcli connection delete MagicMock(stdout="", returncode=0), ] result = wifi_forget("MyNet") assert result["ok"] is True def test_wifi_forget_not_found(mock_subprocess): """wifi_forget returns ok=False when network not saved.""" with patch("src.system.network._has_nmcli", return_value=True): mock_subprocess.return_value = MagicMock( stdout="Other:def-456:wireless\n", returncode=0, ) result = wifi_forget("NonExistent") assert result["ok"] is False # ── wifi_saved_networks() tests ──────────────────────────────────────────────── def test_wifi_saved_networks(mock_subprocess): """wifi_saved_networks returns saved WiFi connections.""" with patch("src.system.network._has_nmcli", return_value=True): mock_subprocess.return_value = MagicMock( stdout="HomeNet:abc-def:wireless\nOfficeNet:ghi-jkl:wireless\neth0:xyz:ethernet\n", returncode=0, ) saved = wifi_saved_networks() assert len(saved) == 2 assert saved[0]["ssid"] == "HomeNet" assert saved[1]["ssid"] == "OfficeNet" # ── hotspot_status() tests ──────────────────────────────────────────────────── def test_hotspot_status_active(mock_subprocess, mock_hostapd_conf): """hotspot_status returns active state when hostapd is running.""" with patch("src.system.network.Path.exists", return_value=True): with patch("src.system.network.Path.read_text", return_value="ssid=Pi-Pedal\nchannel=6\n"): mock_subprocess.side_effect = [ MagicMock(stdout="active\n", returncode=0), # systemctl is-active MagicMock(stdout="Station aa:bb:cc:dd:ee:ff\n", returncode=0), # iw station dump ] status = hotspot_status() assert status["active"] is True assert status["ssid"] == "Pi-Pedal" assert status["clients"] == 1 assert status["ip"] == "192.168.4.1" def test_hotspot_status_inactive(mock_subprocess): """hotspot_status returns inactive when hostapd is stopped.""" mock_subprocess.return_value = MagicMock(stdout="inactive\n", returncode=0) status = hotspot_status() assert status["active"] is False assert status["clients"] == 0 # ── hotspot_enable() / hotspot_disable() tests ───────────────────────────────── def test_hotspot_enable_short_password(): """hotspot_enable rejects passwords shorter than 8 chars.""" result = hotspot_enable("TestAP", "123") assert result["ok"] is False assert "at least 8 characters" in result["error"].lower() def test_hotspot_enable_success(mock_subprocess): """hotspot_enable calls the setup script.""" mock_subprocess.return_value = MagicMock(returncode=0) result = hotspot_enable("TestAP", "longpassword") assert result["ok"] is True def test_hotspot_enable_failure(mock_subprocess): """hotspot_enable returns error on script failure.""" mock_subprocess.return_value = MagicMock(returncode=1, stderr="Script failed") result = hotspot_enable("TestAP", "longpassword") assert result["ok"] is False def test_hotspot_disable_success(mock_subprocess): """hotspot_disable calls the disable script.""" mock_subprocess.return_value = MagicMock(returncode=0) result = hotspot_disable() assert result["ok"] is True def test_hotspot_disable_failure(mock_subprocess): """hotspot_disable returns error on script failure.""" mock_subprocess.return_value = MagicMock(returncode=1, stderr="Script failed") result = hotspot_disable() assert result["ok"] is False # ── get_hotspot_password() tests ────────────────────────────────────────────── def test_get_hotspot_password_generates_on_first_call(tmp_path): """get_hotspot_password generates and persists a random password when none set.""" with patch("src.system.network.CONFIG_PATH", tmp_path / "config.yaml"): pwd = get_hotspot_password() assert len(pwd) >= 8 assert pwd.isalnum() # alphanumeric only # Second call returns the same password pwd2 = get_hotspot_password() assert pwd == pwd2 def test_get_hotspot_password_persists(tmp_path): """get_hotspot_password saves the generated password to config.yaml.""" config_path = tmp_path / "config.yaml" with patch("src.system.network.CONFIG_PATH", config_path): pwd = get_hotspot_password() # Verify it was written to disk assert config_path.exists() import yaml cfg = yaml.safe_load(config_path.read_text()) assert cfg["hotspot"]["password"] == pwd def test_get_hotspot_password_returns_loaded(tmp_path): """get_hotspot_password returns a pre-set password from config.""" config_path = tmp_path / "config.yaml" config_path.parent.mkdir(parents=True, exist_ok=True) import yaml config_path.write_text(yaml.dump({"hotspot": {"password": "MyCust0mP@ss"}})) with patch("src.system.network.CONFIG_PATH", config_path): pwd = get_hotspot_password() assert pwd == "MyCust0mP@ss"