Skip to content

Commit 022a264

Browse files
committed
Improve tests and coverage
1 parent 3dcddd8 commit 022a264

File tree

5 files changed

+170
-7
lines changed

5 files changed

+170
-7
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ tests/__pycache__
1111
.coverage
1212
tmp
1313
todo
14+
.DS_Store

airos/discovery.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,14 +175,13 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
175175
offset += 2
176176

177177
if tlv_length > (len(data) - offset):
178-
log = f"TLV type {tlv_type:#x} length {tlv_length} exceeds remaining data "
179-
_LOGGER.warning(log)
180-
log = f"({len(data) - offset} bytes left). Packet malformed. "
181-
_LOGGER.warning(log)
182-
log = f"Data from TLV start: {data[offset - 3 :].hex()}"
178+
log = (
179+
f"TLV type {tlv_type:#x} length {tlv_length} exceeds remaining data "
180+
f"({len(data) - offset} bytes left). Packet malformed. "
181+
f"Data from TLV start: {data[offset - 3 :].hex()}"
182+
)
183183
_LOGGER.warning(log)
184-
log = f"Malformed packet: {log}"
185-
raise AirOSEndpointError(log)
184+
raise AirOSEndpointError(f"Malformed packet: {log}")
186185

187186
tlv_value: bytes = data[offset : offset + tlv_length]
188187

@@ -195,6 +194,7 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
195194
else:
196195
log = f"Unexpected length for 0x02 TLV (MAC+IP). Expected 10, got {tlv_length}. Value: {tlv_value.hex()}"
197196
_LOGGER.warning(log)
197+
raise AirOSEndpointError(f"Malformed packet: {log}")
198198

199199
elif tlv_type == 0x03:
200200
parsed_info["firmware_version"] = tlv_value.decode(
@@ -213,6 +213,7 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
213213
else:
214214
log = f"Unexpected length for Uptime (Type 0x0A): {tlv_length}. Value: {tlv_value.hex()}"
215215
_LOGGER.warning(log)
216+
raise AirOSEndpointError(f"Malformed packet: {log}")
216217

217218
elif tlv_type == 0x0B:
218219
parsed_info["hostname"] = tlv_value.decode(

tests/test_airos8.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99

1010
import aiohttp
11+
from mashumaro.exceptions import MissingField
1112

1213

1314
# --- Tests for Login and Connection Errors ---
@@ -224,3 +225,36 @@ async def test_provmode_connection_error(airos_device):
224225
pytest.raises(airos.exceptions.AirOSDeviceConnectionError),
225226
):
226227
await airos_device.provmode(active=True)
228+
229+
230+
@pytest.mark.asyncio
231+
async def test_status_missing_required_key_in_json(airos_device):
232+
"""Test status() with a response missing a key required by the dataclass."""
233+
airos_device.connected = True
234+
# Fixture is valid JSON, but is missing the entire 'wireless' block,
235+
# which is a required field for the AirOS8Data dataclass.
236+
invalid_data = {
237+
"host": {"hostname": "test"},
238+
"interfaces": [
239+
{"ifname": "br0", "hwaddr": "11:22:33:44:55:66", "enabled": True}
240+
],
241+
}
242+
243+
mock_status_response = MagicMock()
244+
mock_status_response.__aenter__.return_value = mock_status_response
245+
mock_status_response.text = AsyncMock(return_value=json.dumps(invalid_data))
246+
mock_status_response.status = 200
247+
248+
with (
249+
patch.object(airos_device.session, "get", return_value=mock_status_response),
250+
patch("airos.airos8._LOGGER.exception") as mock_log_exception,
251+
pytest.raises(airos.exceptions.AirOSKeyDataMissingError) as excinfo,
252+
):
253+
await airos_device.status()
254+
255+
# Check that the specific mashumaro error is logged and caught
256+
mock_log_exception.assert_called_once()
257+
assert "Failed to deserialize AirOS data" in mock_log_exception.call_args[0][0]
258+
# --- MODIFICATION START ---
259+
# Assert that the cause of our exception is the correct type from mashumaro
260+
assert isinstance(excinfo.value.__cause__, MissingField)

tests/test_data.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Tests for airos data module."""
2+
3+
from unittest.mock import patch
4+
5+
from airos.data import Host, Wireless
6+
import pytest
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_unknown_enum_values():
11+
"""Test that unknown enum values are handled gracefully."""
12+
# 1. Test for Host.netrole
13+
host_data = {"netrole": "unsupported_role", "other_field": "value"}
14+
format_string = (
15+
"Unknown value '%s' for %s.%s. Please report at "
16+
"https://github.com/CoMPaTech/python-airos/issues so we can add support."
17+
)
18+
with patch("airos.data.logger.warning") as mock_warning:
19+
processed_host = Host.__pre_deserialize__(host_data.copy())
20+
# Verify the unknown value was removed
21+
assert "netrole" not in processed_host
22+
# Verify the other fields remain
23+
assert "other_field" in processed_host
24+
# Verify a warning was logged
25+
mock_warning.assert_called_once_with(
26+
format_string, "unsupported_role", "Host", "netrole"
27+
)
28+
29+
# 2. Test for Wireless (all enums)
30+
wireless_data = {
31+
"mode": "unsupported_mode",
32+
"ieeemode": "unsupported_ieee",
33+
"security": "unsupported_security",
34+
"other_field": "value",
35+
}
36+
with patch("airos.data.logger.warning") as mock_warning:
37+
processed_wireless = Wireless.__pre_deserialize__(wireless_data.copy())
38+
# Verify the unknown values were removed
39+
assert "mode" not in processed_wireless
40+
assert "ieeemode" not in processed_wireless
41+
assert "security" not in processed_wireless
42+
# Verify the other field remains
43+
assert "other_field" in processed_wireless
44+
# Verify warnings were logged for each unknown enum
45+
assert mock_warning.call_count == 3
46+
mock_warning.assert_any_call(
47+
format_string, "unsupported_mode", "Wireless", "mode"
48+
)
49+
mock_warning.assert_any_call(
50+
format_string, "unsupported_ieee", "Wireless", "ieeemode"
51+
)
52+
mock_warning.assert_any_call(
53+
format_string, "unsupported_security", "Wireless", "security"
54+
)

tests/test_discovery.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,3 +304,76 @@ async def test_async_discover_devices_cancelled(mock_datagram_endpoint):
304304

305305
assert "cannot_connect" in str(excinfo.value)
306306
mock_transport.close.assert_called_once()
307+
308+
309+
@pytest.mark.asyncio
310+
async def test_datagram_received_handles_general_exception():
311+
"""Test datagram_received handles a generic exception during parsing."""
312+
mock_callback = AsyncMock()
313+
protocol = AirOSDiscoveryProtocol(mock_callback)
314+
some_data = b"\x01\x06\x00\x00\x00\x00"
315+
host_ip = "192.168.1.100"
316+
317+
with (
318+
patch.object(
319+
protocol, "parse_airos_packet", side_effect=ValueError("A generic error")
320+
) as mock_parse,
321+
patch("airos.discovery._LOGGER.exception") as mock_log_exception,
322+
):
323+
# A generic exception should be caught and re-raised as AirOSDiscoveryError
324+
with pytest.raises(AirOSDiscoveryError):
325+
protocol.datagram_received(some_data, (host_ip, DISCOVERY_PORT))
326+
327+
mock_parse.assert_called_once_with(some_data, host_ip)
328+
mock_callback.assert_not_called()
329+
mock_log_exception.assert_called_once()
330+
assert (
331+
"Error processing AirOS discovery packet"
332+
in mock_log_exception.call_args[0][0]
333+
)
334+
335+
336+
@pytest.mark.parametrize(
337+
"packet_fragment, error_message",
338+
[
339+
# Case 1: TLV type 0x0A (Uptime) with wrong length
340+
(b"\x0a\x00\x02\x01\x02", "Unexpected length for Uptime (Type 0x0A)"),
341+
# Case 2: TLV declared length exceeds remaining packet data
342+
(b"\x0c\x00\xff\x41\x42", "length 255 exceeds remaining data"),
343+
# Case 3: An unknown TLV type
344+
(b"\xff\x01\x02", "Unhandled TLV type: 0xff"),
345+
],
346+
)
347+
@pytest.mark.asyncio
348+
async def test_parse_airos_packet_tlv_edge_cases(packet_fragment, error_message):
349+
"""Test parsing of various malformed TLV entries."""
350+
protocol = AirOSDiscoveryProtocol(AsyncMock())
351+
# A valid header is required to get to the TLV parsing stage
352+
base_packet = b"\x01\x06\x00\x00\x00\x00"
353+
malformed_packet = base_packet + packet_fragment
354+
host_ip = "192.168.1.100"
355+
356+
with pytest.raises(AirOSEndpointError) as excinfo:
357+
protocol.parse_airos_packet(malformed_packet, host_ip)
358+
359+
assert error_message in str(excinfo.value)
360+
361+
362+
@pytest.mark.asyncio
363+
async def test_async_discover_devices_generic_oserror(mock_datagram_endpoint):
364+
"""Test discovery handles a generic OSError during endpoint creation."""
365+
mock_transport, _ = mock_datagram_endpoint
366+
367+
with (
368+
patch("asyncio.get_running_loop") as mock_get_loop,
369+
pytest.raises(AirOSEndpointError) as excinfo,
370+
):
371+
mock_loop = mock_get_loop.return_value
372+
# Simulate an OSError that is NOT 'address in use'
373+
mock_loop.create_datagram_endpoint = AsyncMock(
374+
side_effect=OSError(13, "Permission denied")
375+
)
376+
await async_discover_devices(timeout=1)
377+
378+
assert "cannot_connect" in str(excinfo.value)
379+
mock_transport.close.assert_not_called()

0 commit comments

Comments
 (0)