55import socket # Add this import
66from 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
1010import pytest
1111
1212
@@ -37,7 +37,7 @@ async def mock_airos_packet() -> bytes:
3737@pytest .mark .asyncio
3838async 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
6262async 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
8080async 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
9898async 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
116116async 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):
140140async 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
156156async 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
173173async 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
184184async 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
199199async 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 ()
0 commit comments