mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-13 14:48:06 +00:00
The /wifi/* routes drive the host's real networking and the registry
routes reach GitHub, and neither had endpoint-level tests. Covering them
surfaced a bug affecting six endpoints.
Six handlers read their body as `request.get_json() or {}`. The `or {}`
says every field is optional and a missing body should fall back to
defaults — but get_json() without silent=True raises UnsupportedMediaType
when there is no JSON Content-Type, and it raises before `or {}` is ever
evaluated. Each handler's catch-all then reported that as a 500. So
POSTing with no body — what curl sends by default, and what a fetch()
without options sends — failed on /plugins/store/refresh,
/display/on-demand/start, /plugins/config/reset,
/plugins/of-the-day/json/delete, /plugins/{id}/limits and
/plugins/authenticate/spotify. The shipped UI always sends a JSON object,
which is why this stayed hidden.
All six now use silent=True. test_api_v3_optional_body.py covers the
affected endpoints and adds a source check, since the combination of
`or <default>` with a non-silent read is self-contradictory wherever it
appears and is easier to catch by inspection than by exercising each
endpoint by hand.
Also adds test/_api_v3_test_helpers.py: the blueprint holds its managers
on a module-level singleton rather than in Flask app state, so a test
that mocks them leaks into every later test unless the originals are
restored. The existing _make_client() does this for unittest classes;
this is the pytest-fixture equivalent, for the five suites still to come.
69 endpoint tests: connect/disconnect/AP/radio including the string-aware
boolean coercion these endpoints deliberately use, the radio's
lockout-refusal path, registry refresh and fetch-from-URL, and a guard
that WiFiManager is never constructed for real.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
241 lines
11 KiB
Python
241 lines
11 KiB
Python
"""
|
|
Endpoint tests for the /wifi/* routes in api_v3.
|
|
|
|
These routes drive the host's actual networking — connecting, dropping a
|
|
connection, switching the radio off — and had no endpoint-level tests at
|
|
all. WiFiManager is mocked throughout; nothing here may touch real
|
|
networking.
|
|
|
|
Each handler does `from src.wifi_manager import WiFiManager` inside the
|
|
function body, so the patch target is the class at its definition site.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
|
|
|
|
|
@pytest.fixture
|
|
def wifi_manager():
|
|
"""Patch WiFiManager where it is defined; yield the instance mock."""
|
|
with patch("src.wifi_manager.WiFiManager") as cls:
|
|
instance = MagicMock()
|
|
cls.return_value = instance
|
|
yield instance
|
|
|
|
|
|
class TestConnect:
|
|
URL = "/api/v3/wifi/connect"
|
|
|
|
def test_success(self, api_v3_client, wifi_manager):
|
|
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
|
|
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "pw"})
|
|
assert response.status_code == 200
|
|
assert response.get_json()["message"] == "Connected to HomeNet"
|
|
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "pw")
|
|
|
|
def test_missing_body_rejected(self, api_v3_client, wifi_manager):
|
|
response = api_v3_client.post(self.URL, json={})
|
|
assert response.status_code == 400
|
|
wifi_manager.connect_to_network.assert_not_called()
|
|
|
|
def test_missing_ssid_rejected(self, api_v3_client, wifi_manager):
|
|
response = api_v3_client.post(self.URL, json={"password": "pw"})
|
|
assert response.status_code == 400
|
|
assert "SSID is required" in response.get_json()["message"]
|
|
wifi_manager.connect_to_network.assert_not_called()
|
|
|
|
@pytest.mark.parametrize("ssid", ["", " ", "\t"])
|
|
def test_blank_ssid_rejected(self, api_v3_client, wifi_manager, ssid):
|
|
response = api_v3_client.post(self.URL, json={"ssid": ssid})
|
|
assert response.status_code == 400
|
|
wifi_manager.connect_to_network.assert_not_called()
|
|
|
|
def test_ssid_is_trimmed(self, api_v3_client, wifi_manager):
|
|
wifi_manager.connect_to_network.return_value = (True, "ok")
|
|
api_v3_client.post(self.URL, json={"ssid": " HomeNet "})
|
|
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "")
|
|
|
|
def test_missing_password_becomes_empty_string(self, api_v3_client, wifi_manager):
|
|
wifi_manager.connect_to_network.return_value = (True, "ok")
|
|
api_v3_client.post(self.URL, json={"ssid": "OpenNet"})
|
|
wifi_manager.connect_to_network.assert_called_once_with("OpenNet", "")
|
|
|
|
def test_null_password_becomes_empty_string(self, api_v3_client, wifi_manager):
|
|
wifi_manager.connect_to_network.return_value = (True, "ok")
|
|
api_v3_client.post(self.URL, json={"ssid": "OpenNet", "password": None})
|
|
wifi_manager.connect_to_network.assert_called_once_with("OpenNet", "")
|
|
|
|
def test_failure_reports_the_managers_reason(self, api_v3_client, wifi_manager):
|
|
wifi_manager.connect_to_network.return_value = (False, "Bad password")
|
|
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
|
assert response.status_code == 400
|
|
assert response.get_json()["message"] == "Bad password"
|
|
|
|
def test_failure_without_reason_uses_fallback_text(self, api_v3_client, wifi_manager):
|
|
wifi_manager.connect_to_network.return_value = (False, None)
|
|
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
|
assert response.status_code == 400
|
|
assert response.get_json()["message"] == "Failed to connect to network"
|
|
|
|
def test_manager_exception_is_a_500_without_leaking_internals(
|
|
self, api_v3_client, wifi_manager):
|
|
wifi_manager.connect_to_network.side_effect = RuntimeError(
|
|
"/usr/lib/secret/path blew up")
|
|
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
|
assert response.status_code == 500
|
|
body = response.get_json()
|
|
assert body["message"] == "An error occurred; see logs for details"
|
|
# `details` comes from describe_exception, which is deliberately
|
|
# safe to return (redacted, capped) — it names the type.
|
|
assert "RuntimeError" in body["details"]
|
|
|
|
|
|
class TestDisconnect:
|
|
URL = "/api/v3/wifi/disconnect"
|
|
|
|
def test_success(self, api_v3_client, wifi_manager):
|
|
wifi_manager.disconnect_from_network.return_value = (True, "Disconnected")
|
|
response = api_v3_client.post(self.URL)
|
|
assert response.status_code == 200
|
|
assert response.get_json()["message"] == "Disconnected"
|
|
|
|
def test_failure(self, api_v3_client, wifi_manager):
|
|
wifi_manager.disconnect_from_network.return_value = (False, "Not connected")
|
|
response = api_v3_client.post(self.URL)
|
|
assert response.status_code == 400
|
|
assert response.get_json()["message"] == "Not connected"
|
|
|
|
def test_failure_without_reason_uses_fallback(self, api_v3_client, wifi_manager):
|
|
wifi_manager.disconnect_from_network.return_value = (False, "")
|
|
response = api_v3_client.post(self.URL)
|
|
assert response.get_json()["message"] == "Failed to disconnect from network"
|
|
|
|
def test_exception_is_a_500(self, api_v3_client, wifi_manager):
|
|
wifi_manager.disconnect_from_network.side_effect = OSError("nmcli missing")
|
|
assert api_v3_client.post(self.URL).status_code == 500
|
|
|
|
|
|
class TestApMode:
|
|
ENABLE = "/api/v3/wifi/ap/enable"
|
|
DISABLE = "/api/v3/wifi/ap/disable"
|
|
|
|
def test_enable_success(self, api_v3_client, wifi_manager):
|
|
wifi_manager.enable_ap_mode.return_value = (True, "AP enabled")
|
|
response = api_v3_client.post(self.ENABLE, json={})
|
|
assert response.status_code == 200
|
|
wifi_manager.enable_ap_mode.assert_called_once_with(force=False)
|
|
|
|
@pytest.mark.parametrize("raw,expected", [
|
|
(True, True), (False, False),
|
|
("true", True), ("TRUE", True), ("1", True),
|
|
("false", False), ("no", False), ("yes", False),
|
|
(1, False), # only real True or the listed strings count
|
|
])
|
|
def test_force_coercion(self, api_v3_client, wifi_manager, raw, expected):
|
|
wifi_manager.enable_ap_mode.return_value = (True, "ok")
|
|
api_v3_client.post(self.ENABLE, json={"force": raw})
|
|
wifi_manager.enable_ap_mode.assert_called_once_with(force=expected)
|
|
|
|
def test_enable_without_body(self, api_v3_client, wifi_manager):
|
|
wifi_manager.enable_ap_mode.return_value = (True, "ok")
|
|
assert api_v3_client.post(self.ENABLE).status_code == 200
|
|
|
|
def test_enable_failure(self, api_v3_client, wifi_manager):
|
|
wifi_manager.enable_ap_mode.return_value = (False, "hostapd missing")
|
|
response = api_v3_client.post(self.ENABLE, json={})
|
|
assert response.status_code == 400
|
|
assert response.get_json()["message"] == "hostapd missing"
|
|
|
|
def test_disable_success(self, api_v3_client, wifi_manager):
|
|
wifi_manager.disable_ap_mode.return_value = (True, "AP disabled")
|
|
assert api_v3_client.post(self.DISABLE).status_code == 200
|
|
|
|
def test_disable_failure(self, api_v3_client, wifi_manager):
|
|
wifi_manager.disable_ap_mode.return_value = (False, "not running")
|
|
assert api_v3_client.post(self.DISABLE).status_code == 400
|
|
|
|
def test_enable_exception_is_a_500(self, api_v3_client, wifi_manager):
|
|
wifi_manager.enable_ap_mode.side_effect = RuntimeError("boom")
|
|
assert api_v3_client.post(self.ENABLE, json={}).status_code == 500
|
|
|
|
|
|
class TestRadio:
|
|
URL = "/api/v3/wifi/radio"
|
|
|
|
def test_get_state(self, api_v3_client, wifi_manager):
|
|
wifi_manager.get_wifi_radio_state.return_value = {
|
|
"enabled": True, "ethernet_connected": False}
|
|
response = api_v3_client.get(self.URL)
|
|
assert response.status_code == 200
|
|
assert response.get_json()["data"]["enabled"] is True
|
|
|
|
def test_get_state_exception_is_a_500(self, api_v3_client, wifi_manager):
|
|
wifi_manager.get_wifi_radio_state.side_effect = OSError("rfkill missing")
|
|
assert api_v3_client.get(self.URL).status_code == 500
|
|
|
|
def test_enabled_is_required(self, api_v3_client, wifi_manager):
|
|
response = api_v3_client.post(self.URL, json={})
|
|
assert response.status_code == 400
|
|
assert "enabled is required" in response.get_json()["message"]
|
|
wifi_manager.set_wifi_radio.assert_not_called()
|
|
|
|
def test_enable_success(self, api_v3_client, wifi_manager):
|
|
wifi_manager.set_wifi_radio.return_value = (True, "Radio on", None)
|
|
wifi_manager.get_wifi_radio_state.return_value = {"enabled": True}
|
|
response = api_v3_client.post(self.URL, json={"enabled": True})
|
|
assert response.status_code == 200
|
|
wifi_manager.set_wifi_radio.assert_called_once_with(True, force=False)
|
|
|
|
@pytest.mark.parametrize("raw,expected", [
|
|
(True, True), ("true", True), ("1", True), ("yes", True),
|
|
(False, False), ("false", False), ("off", False), (0, False),
|
|
])
|
|
def test_enabled_coercion_is_string_aware(
|
|
self, api_v3_client, wifi_manager, raw, expected):
|
|
# bool("false") is True, so the endpoint parses strings explicitly
|
|
# rather than trusting truthiness — it is a public contract, not
|
|
# only the shipped UI which always sends real JSON booleans.
|
|
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
|
|
wifi_manager.get_wifi_radio_state.return_value = {}
|
|
api_v3_client.post(self.URL, json={"enabled": raw})
|
|
wifi_manager.set_wifi_radio.assert_called_once_with(expected, force=False)
|
|
|
|
def test_force_passed_through(self, api_v3_client, wifi_manager):
|
|
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
|
|
wifi_manager.get_wifi_radio_state.return_value = {}
|
|
api_v3_client.post(self.URL, json={"enabled": False, "force": "true"})
|
|
wifi_manager.set_wifi_radio.assert_called_once_with(False, force=True)
|
|
|
|
def test_refusal_reports_reason(self, api_v3_client, wifi_manager):
|
|
# Disabling the radio without Ethernet would lock the user out of
|
|
# this very interface, so the manager can refuse with a reason.
|
|
wifi_manager.set_wifi_radio.return_value = (
|
|
False, "Refusing: no wired fallback", "no_ethernet")
|
|
response = api_v3_client.post(self.URL, json={"enabled": False})
|
|
assert response.status_code == 400
|
|
body = response.get_json()
|
|
assert body["reason"] == "no_ethernet"
|
|
assert "Refusing" in body["message"]
|
|
|
|
def test_exception_is_a_500(self, api_v3_client, wifi_manager):
|
|
wifi_manager.set_wifi_radio.side_effect = RuntimeError("boom")
|
|
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
|
|
|
|
|
|
class TestNoRealNetworking:
|
|
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
|
|
# Guard against a future refactor moving the import to module level,
|
|
# where the fixture's patch of the definition site would stop
|
|
# applying and the tests would start driving real networking.
|
|
with patch("src.wifi_manager.WiFiManager") as cls:
|
|
cls.return_value.disconnect_from_network.return_value = (True, "ok")
|
|
api_v3_client.post("/api/v3/wifi/disconnect")
|
|
assert cls.called
|