"""Unit tests for APIServer packet parsing and message handling.""" from unittest.mock import MagicMock, patch from aioesphomeapi._frame_helper.packets import make_plain_text_packets from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] AuthenticationRequest, AuthenticationResponse, DisconnectRequest, DisconnectResponse, HelloRequest, HelloResponse, PingRequest, PingResponse, ) from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()} # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def make_packet(msg) -> bytes: """Serialize a protobuf message into a plain-text ESPHome packet.""" msg_type = PROTO_TO_MESSAGE_TYPE[msg.__class__] data = msg.SerializeToString() packets = make_plain_text_packets([(msg_type, data)]) # make_plain_text_packets returns a list of memoryview/bytes — join into one if isinstance(packets, (list, tuple)): return b"".join(bytes(p) for p in packets) return bytes(packets) class ConcreteAPIServer: """Concrete subclass of APIServer for testing — records handled messages.""" def __init__(self): from linux_voice_assistant.api_server import APIServer class _Concrete(APIServer): def __init__(self_inner): super().__init__("test-server") self_inner.handled = [] def handle_message(self_inner, msg): self_inner.handled.append(msg) return [] self._cls = _Concrete self.instance = _Concrete() @property def server(self): return self.instance def make_server(): """Return a connected APIServer instance with a mock transport.""" wrapper = ConcreteAPIServer() server = wrapper.server transport = MagicMock() written = [] def capture_writelines(data): # data may be a list of memoryview/bytes — flatten to a single bytes object if isinstance(data, (list, tuple)): written.append(b"".join(bytes(d) for d in data)) else: written.append(bytes(data)) transport.writelines = capture_writelines server._transport = transport server._writelines = capture_writelines server._written = written return server def get_sent_messages(server): """Decode all messages the server sent back via writelines.""" from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO messages = [] for raw in server._written: # raw is bytes — parse the plain-text framing manually pos = 0 while pos < len(raw): # preamble if (raw[pos] if isinstance(raw[pos], int) else raw[pos][0]) != 0x00: break pos += 1 # length varuint length = 0 bitpos = 0 while True: b = raw[pos] pos += 1 length |= (b & 0x7F) << bitpos if not (b & 0x80): break bitpos += 7 # msg_type varuint msg_type = 0 bitpos = 0 while True: b = raw[pos] pos += 1 msg_type |= (b & 0x7F) << bitpos if not (b & 0x80): break bitpos += 7 # payload payload = raw[pos : pos + length] pos += length msg_cls = MESSAGE_TYPE_TO_PROTO[msg_type] messages.append(msg_cls.FromString(payload)) return messages # --------------------------------------------------------------------------- # connection_made / connection_lost # --------------------------------------------------------------------------- class TestConnection: def test_connection_made_stores_transport(self): from linux_voice_assistant.api_server import APIServer class _Concrete(APIServer): def __init__(self): super().__init__("test") def handle_message(self, msg): return [] server = _Concrete() transport = MagicMock() transport.writelines = MagicMock() loop = MagicMock() with patch("linux_voice_assistant.api_server.asyncio.get_running_loop", return_value=loop): server.connection_made(transport) assert server._transport is transport def test_connection_lost_clears_transport(self): server = make_server() server.connection_lost(None) assert server._transport is None assert server._writelines is None def test_connection_lost_clears_loop(self): server = make_server() server._loop = MagicMock() server.connection_lost(None) assert server._loop is None # --------------------------------------------------------------------------- # HelloRequest → HelloResponse # --------------------------------------------------------------------------- class TestHelloHandshake: def test_hello_request_yields_hello_response(self): server = make_server() server.data_received(make_packet(HelloRequest(client_info="test"))) msgs = get_sent_messages(server) assert any(isinstance(m, HelloResponse) for m in msgs) def test_hello_response_contains_server_name(self): server = make_server() server.data_received(make_packet(HelloRequest(client_info="test"))) msgs = get_sent_messages(server) hello = next(m for m in msgs if isinstance(m, HelloResponse)) assert hello.name == "test-server" def test_hello_response_has_api_version(self): server = make_server() server.data_received(make_packet(HelloRequest(client_info="test"))) msgs = get_sent_messages(server) hello = next(m for m in msgs if isinstance(m, HelloResponse)) assert hello.api_version_major == 1 assert hello.api_version_minor == 10 def test_hello_does_not_call_handle_message(self): server = make_server() server.data_received(make_packet(HelloRequest(client_info="test"))) assert server.handled == [] # --------------------------------------------------------------------------- # PingRequest → PingResponse # --------------------------------------------------------------------------- class TestPing: def test_ping_request_yields_ping_response(self): server = make_server() server.data_received(make_packet(PingRequest())) msgs = get_sent_messages(server) assert any(isinstance(m, PingResponse) for m in msgs) # --------------------------------------------------------------------------- # DisconnectRequest → DisconnectResponse # --------------------------------------------------------------------------- class TestDisconnect: def test_disconnect_request_yields_disconnect_response(self): server = make_server() server.data_received(make_packet(DisconnectRequest())) msgs = get_sent_messages(server) assert any(isinstance(m, DisconnectResponse) for m in msgs) def test_disconnect_closes_transport(self): server = make_server() mock_transport = MagicMock() server._transport = mock_transport server.data_received(make_packet(DisconnectRequest())) mock_transport.close.assert_called_once() def test_disconnect_clears_transport_reference(self): server = make_server() server.data_received(make_packet(DisconnectRequest())) assert server._transport is None # --------------------------------------------------------------------------- # AuthenticationRequest → AuthenticationResponse # --------------------------------------------------------------------------- class TestAuthentication: def test_auth_request_yields_auth_response(self): server = make_server() server.data_received(make_packet(AuthenticationRequest())) msgs = get_sent_messages(server) assert any(isinstance(m, AuthenticationResponse) for m in msgs) # --------------------------------------------------------------------------- # Buffer management # --------------------------------------------------------------------------- class TestBufferManagement: def test_buffer_is_none_after_complete_packet(self): server = make_server() server.data_received(make_packet(PingRequest())) assert server._buffer is None def test_partial_packet_stays_in_buffer(self): server = make_server() full = make_packet(PingRequest()) # Send only half the packet server.data_received(full[: len(full) // 2]) assert server._buffer is not None def test_split_packet_reassembled_correctly(self): server = make_server() full = make_packet(PingRequest()) half = len(full) // 2 server.data_received(full[:half]) server.data_received(full[half:]) msgs = get_sent_messages(server) assert any(isinstance(m, PingResponse) for m in msgs) def test_two_packets_in_one_data_received(self): server = make_server() data = make_packet(PingRequest()) + make_packet(PingRequest()) server.data_received(data) msgs = get_sent_messages(server) assert sum(1 for m in msgs if isinstance(m, PingResponse)) == 2 def test_buffer_len_tracks_correctly(self): server = make_server() full = make_packet(PingRequest()) server.data_received(full[:2]) assert server._buffer_len == 2 def test_buffer_cleared_after_full_packet(self): server = make_server() server.data_received(make_packet(PingRequest())) assert server._buffer_len == 0 # --------------------------------------------------------------------------- # _read_varuint # --------------------------------------------------------------------------- class TestReadVarint: def _make_server_with_buffer(self, data: bytes): server = make_server() server._buffer = data server._buffer_len = len(data) server._pos = 0 return server def test_reads_single_byte_varuint(self): server = self._make_server_with_buffer(bytes([0x05])) assert server._read_varuint() == 5 def test_reads_two_byte_varuint(self): # 300 encoded as varuint = 0xAC 0x02 server = self._make_server_with_buffer(bytes([0xAC, 0x02])) assert server._read_varuint() == 300 def test_returns_minus_one_on_empty_buffer(self): server = make_server() server._buffer = None assert server._read_varuint() == -1 def test_returns_zero_for_zero_byte(self): server = self._make_server_with_buffer(bytes([0x00])) assert server._read_varuint() == 0