Skip to content

Commit 3799dac

Browse files
committed
Add appropriate testing
1 parent 850b5c7 commit 3799dac

File tree

3 files changed

+137
-27
lines changed

3 files changed

+137
-27
lines changed

tests/conftest.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""Ubiquity AirOS test fixtures."""
22

3+
from unittest.mock import AsyncMock, MagicMock, patch
34
from airos.airos8 import AirOS
5+
from airos.discovery import AirOSDiscoveryProtocol
46
import pytest
57

68
import aiohttp
9+
import asyncio
710

811

912
@pytest.fixture
@@ -19,3 +22,26 @@ async def airos_device(base_url):
1922
instance = AirOS(base_url, "username", "password", session, use_ssl=False)
2023
yield instance
2124
await session.close()
25+
26+
27+
@pytest.fixture
28+
def mock_datagram_endpoint():
29+
"""Fixture to mock the creation of the UDP datagram endpoint."""
30+
# Define the mock objects FIRST, so they are in scope
31+
mock_transport = MagicMock(spec=asyncio.DatagramTransport)
32+
mock_protocol_instance = MagicMock(spec=AirOSDiscoveryProtocol)
33+
34+
# Now, define the AsyncMock using the pre-defined variables
35+
mock_create_datagram_endpoint = AsyncMock(
36+
return_value=(mock_transport, mock_protocol_instance)
37+
)
38+
39+
with patch(
40+
"asyncio.get_running_loop"
41+
) as mock_get_loop, patch(
42+
"airos.discovery.AirOSDiscoveryProtocol", new=MagicMock(return_value=mock_protocol_instance)
43+
):
44+
mock_loop = mock_get_loop.return_value
45+
mock_loop.create_datagram_endpoint = mock_create_datagram_endpoint
46+
47+
yield mock_transport, mock_protocol_instance

tests/test_discovery.py

Lines changed: 107 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import socket # Add this import
66
from unittest.mock import AsyncMock, MagicMock, patch
77

8-
from airos.discovery import DISCOVERY_PORT, AirosDiscoveryProtocol
9-
from airos.exceptions import AirosDiscoveryError, AirosEndpointError
8+
from airos.discovery import DISCOVERY_PORT, AirOSDiscoveryProtocol, async_discover_devices
9+
from airos.exceptions import AirOSDiscoveryError, AirOSEndpointError, AirOSListenerError
1010
import pytest
1111

1212

@@ -37,7 +37,7 @@ async def mock_airos_packet() -> bytes:
3737
@pytest.mark.asyncio
3838
async def test_parse_airos_packet_success(mock_airos_packet):
3939
"""Test parse_airos_packet with a valid packet containing scrubbed data."""
40-
protocol = AirosDiscoveryProtocol(
40+
protocol = AirOSDiscoveryProtocol(
4141
AsyncMock()
4242
) # Callback won't be called directly in this unit test
4343
host_ip = (
@@ -61,31 +61,31 @@ async def test_parse_airos_packet_success(mock_airos_packet):
6161
@pytest.mark.asyncio
6262
async def test_parse_airos_packet_invalid_header():
6363
"""Test parse_airos_packet with an invalid header."""
64-
protocol = AirosDiscoveryProtocol(AsyncMock())
64+
protocol = AirOSDiscoveryProtocol(AsyncMock())
6565
invalid_data = b"\x00\x00\x00\x00\x00\x00" + b"someotherdata"
6666
host_ip = "192.168.1.100"
6767

6868
# Patch the _LOGGER.debug to verify the log message
6969
with patch("airos.discovery._LOGGER.debug") as mock_log_debug:
70-
with pytest.raises(AirosEndpointError):
70+
with pytest.raises(AirOSEndpointError):
7171
protocol.parse_airos_packet(invalid_data, host_ip)
7272
mock_log_debug.assert_called_once()
7373
assert (
74-
"does not start with expected Airos header"
74+
"does not start with expected AirOS header"
7575
in mock_log_debug.call_args[0][0]
7676
)
7777

7878

7979
@pytest.mark.asyncio
8080
async def test_parse_airos_packet_too_short():
8181
"""Test parse_airos_packet with data too short for header."""
82-
protocol = AirosDiscoveryProtocol(AsyncMock())
82+
protocol = AirOSDiscoveryProtocol(AsyncMock())
8383
too_short_data = b"\x01\x06\x00"
8484
host_ip = "192.168.1.100"
8585

8686
# Patch the _LOGGER.debug to verify the log message
8787
with patch("airos.discovery._LOGGER.debug") as mock_log_debug:
88-
with pytest.raises(AirosEndpointError):
88+
with pytest.raises(AirOSEndpointError):
8989
protocol.parse_airos_packet(too_short_data, host_ip)
9090
mock_log_debug.assert_called_once()
9191
assert (
@@ -97,7 +97,7 @@ async def test_parse_airos_packet_too_short():
9797
@pytest.mark.asyncio
9898
async def test_parse_airos_packet_truncated_tlv():
9999
"""Test parse_airos_packet with a truncated TLV."""
100-
protocol = AirosDiscoveryProtocol(AsyncMock())
100+
protocol = AirOSDiscoveryProtocol(AsyncMock())
101101
# Header + MAC TLV (valid) + then a truncated TLV_IP
102102
truncated_data = (
103103
b"\x01\x06\x00\x00\x00\x00" # Header
@@ -107,16 +107,16 @@ async def test_parse_airos_packet_truncated_tlv():
107107
)
108108
host_ip = "192.168.1.100"
109109

110-
# Expect AirosEndpointError due to struct.error or IndexError
111-
with pytest.raises(AirosEndpointError):
110+
# Expect AirOSEndpointError due to struct.error or IndexError
111+
with pytest.raises(AirOSEndpointError):
112112
protocol.parse_airos_packet(truncated_data, host_ip)
113113

114114

115115
@pytest.mark.asyncio
116116
async def test_datagram_received_calls_callback(mock_airos_packet):
117117
"""Test that datagram_received correctly calls the callback."""
118118
mock_callback = AsyncMock()
119-
protocol = AirosDiscoveryProtocol(mock_callback)
119+
protocol = AirOSDiscoveryProtocol(mock_callback)
120120
host_ip = "192.168.1.3" # Sender IP
121121

122122
with patch("asyncio.create_task") as mock_create_task:
@@ -140,13 +140,13 @@ async def test_datagram_received_calls_callback(mock_airos_packet):
140140
async def test_datagram_received_handles_parsing_error():
141141
"""Test datagram_received handles exceptions during parsing."""
142142
mock_callback = AsyncMock()
143-
protocol = AirosDiscoveryProtocol(mock_callback)
143+
protocol = AirOSDiscoveryProtocol(mock_callback)
144144
invalid_data = b"\x00\x00" # Too short, will cause parsing error
145145
host_ip = "192.168.1.100"
146146

147147
with patch("airos.discovery._LOGGER.exception") as mock_log_exception:
148-
# datagram_received catches errors internally and re-raises AirosDiscoveryError
149-
with pytest.raises(AirosDiscoveryError):
148+
# datagram_received catches errors internally and re-raises AirOSDiscoveryError
149+
with pytest.raises(AirOSDiscoveryError):
150150
protocol.datagram_received(invalid_data, (host_ip, DISCOVERY_PORT))
151151
mock_callback.assert_not_called()
152152
mock_log_exception.assert_called_once() # Ensure exception is logged
@@ -155,7 +155,7 @@ async def test_datagram_received_handles_parsing_error():
155155
@pytest.mark.asyncio
156156
async def test_connection_made_sets_transport():
157157
"""Test connection_made sets up transport and socket options."""
158-
protocol = AirosDiscoveryProtocol(AsyncMock())
158+
protocol = AirOSDiscoveryProtocol(AsyncMock())
159159
mock_transport = MagicMock(spec=asyncio.DatagramTransport)
160160
mock_sock = MagicMock(spec=socket.socket) # Corrected: socket import added
161161
mock_transport.get_extra_info.return_value = mock_sock
@@ -172,24 +172,24 @@ async def test_connection_made_sets_transport():
172172
@pytest.mark.asyncio
173173
async def test_connection_lost_without_exception():
174174
"""Test connection_lost without an exception."""
175-
protocol = AirosDiscoveryProtocol(AsyncMock())
175+
protocol = AirOSDiscoveryProtocol(AsyncMock())
176176
with patch("airos.discovery._LOGGER.debug") as mock_log_debug:
177177
protocol.connection_lost(None)
178178
mock_log_debug.assert_called_once_with(
179-
"AirosDiscoveryProtocol connection lost."
179+
"AirOSDiscoveryProtocol connection lost."
180180
)
181181

182182

183183
@pytest.mark.asyncio
184184
async def test_connection_lost_with_exception():
185185
"""Test connection_lost with an exception."""
186-
protocol = AirosDiscoveryProtocol(AsyncMock())
186+
protocol = AirOSDiscoveryProtocol(AsyncMock())
187187
test_exception = Exception("Test connection lost error")
188188
with (
189189
patch("airos.discovery._LOGGER.exception") as mock_log_exception,
190190
pytest.raises(
191-
AirosDiscoveryError
192-
), # connection_lost now re-raises AirosDiscoveryError
191+
AirOSDiscoveryError
192+
), # connection_lost now re-raises AirOSDiscoveryError
193193
):
194194
protocol.connection_lost(test_exception)
195195
mock_log_exception.assert_called_once()
@@ -198,10 +198,94 @@ async def test_connection_lost_with_exception():
198198
@pytest.mark.asyncio
199199
async def test_error_received():
200200
"""Test error_received logs the error."""
201-
protocol = AirosDiscoveryProtocol(AsyncMock())
201+
protocol = AirOSDiscoveryProtocol(AsyncMock())
202202
test_exception = Exception("Test network error")
203203
with patch("airos.discovery._LOGGER.error") as mock_log_error:
204204
protocol.error_received(test_exception)
205205
mock_log_error.assert_called_once_with(
206-
f"UDP error received in AirosDiscoveryProtocol: {test_exception}"
206+
f"UDP error received in AirOSDiscoveryProtocol: {test_exception}"
207207
)
208+
209+
# Front-end discovery tests
210+
211+
@pytest.mark.asyncio
212+
async def test_async_discover_devices_success(mock_airos_packet, mock_datagram_endpoint):
213+
"""Test the high-level discovery function on a successful run."""
214+
mock_transport, mock_protocol_instance = mock_datagram_endpoint
215+
216+
discovered_devices = {}
217+
218+
def mock_protocol_factory(callback):
219+
def inner_callback(device_info):
220+
mac_address = device_info.get("mac_address")
221+
if mac_address:
222+
discovered_devices[mac_address] = device_info
223+
224+
return MagicMock(callback=inner_callback)
225+
226+
with patch(
227+
"airos.discovery.AirOSDiscoveryProtocol", new=MagicMock(side_effect=mock_protocol_factory)
228+
):
229+
230+
async def _simulate_discovery():
231+
await asyncio.sleep(0.1)
232+
233+
protocol = AirOSDiscoveryProtocol(MagicMock()) # Create a real protocol instance just for parsing
234+
parsed_data = protocol.parse_airos_packet(mock_airos_packet, "192.168.1.3")
235+
236+
mock_protocol_factory(MagicMock()).callback(parsed_data)
237+
238+
with patch("asyncio.sleep", new=AsyncMock()):
239+
discovery_task = asyncio.create_task(async_discover_devices(timeout=1))
240+
241+
await _simulate_discovery()
242+
243+
await discovery_task
244+
245+
assert "01:23:45:67:89:CD" in discovered_devices
246+
assert discovered_devices["01:23:45:67:89:CD"]["hostname"] == "name"
247+
mock_transport.close.assert_called_once()
248+
249+
250+
@pytest.mark.asyncio
251+
async def test_async_discover_devices_no_devices(mock_datagram_endpoint):
252+
"""Test discovery returns an empty dict if no devices are found."""
253+
mock_transport, _ = mock_datagram_endpoint
254+
255+
with patch("asyncio.sleep", new=AsyncMock()):
256+
result = await async_discover_devices(timeout=1)
257+
258+
assert result == {}
259+
mock_transport.close.assert_called_once()
260+
261+
262+
@pytest.mark.asyncio
263+
async def test_async_discover_devices_oserror(mock_datagram_endpoint):
264+
"""Test discovery handles OSError during endpoint creation."""
265+
mock_transport, _ = mock_datagram_endpoint
266+
267+
with patch(
268+
"asyncio.get_running_loop"
269+
) as mock_get_loop, pytest.raises(AirOSEndpointError) as excinfo:
270+
mock_loop = mock_get_loop.return_value
271+
mock_loop.create_datagram_endpoint = AsyncMock(side_effect=OSError(98, "Address in use"))
272+
273+
await async_discover_devices(timeout=1)
274+
275+
assert "address_in_use" in str(excinfo.value)
276+
mock_transport.close.assert_not_called()
277+
278+
279+
@pytest.mark.asyncio
280+
async def test_async_discover_devices_cancelled(mock_datagram_endpoint):
281+
"""Test discovery handles CancelledError during the timeout."""
282+
mock_transport, _ = mock_datagram_endpoint
283+
284+
# Patch asyncio.sleep to immediately raise CancelledError
285+
with patch(
286+
"asyncio.sleep", new=AsyncMock(side_effect=asyncio.CancelledError)
287+
), pytest.raises(AirOSListenerError) as excinfo:
288+
await async_discover_devices(timeout=1)
289+
290+
assert "cannot_connect" in str(excinfo.value)
291+
mock_transport.close.assert_called_once()

tests/test_stations.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ async def test_ap_corners(airos_device, base_url, mode="ap-ptp"):
105105
try:
106106
assert await airos_device.login()
107107
assert False
108-
except airos.exceptions.ConnectionSetupError:
108+
except airos.exceptions.AirOSConnectionSetupError:
109109
assert True
110110

111111
mock_login_response.cookies = cookie
@@ -124,7 +124,7 @@ async def test_ap_corners(airos_device, base_url, mode="ap-ptp"):
124124
try:
125125
assert await airos_device.login()
126126
assert False
127-
except airos.exceptions.DataMissingError:
127+
except airos.exceptions.AirOSDataMissingError:
128128
assert True
129129

130130
mock_login_response.text = AsyncMock(return_value="{}")
@@ -135,13 +135,13 @@ async def test_ap_corners(airos_device, base_url, mode="ap-ptp"):
135135
try:
136136
assert await airos_device.login()
137137
assert False
138-
except airos.exceptions.ConnectionAuthenticationError:
138+
except airos.exceptions.AirOSConnectionAuthenticationError:
139139
assert True
140140

141141
mock_login_response.status = 200
142142
with patch.object(airos_device.session, "post", side_effect=aiohttp.ClientError):
143143
try:
144144
assert await airos_device.login()
145145
assert False
146-
except airos.exceptions.DeviceConnectionError:
146+
except airos.exceptions.AirOSDeviceConnectionError:
147147
assert True

0 commit comments

Comments
 (0)