Files
pi-multifx-pedal/tests/test_network.py
T

362 lines
14 KiB
Python

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