From 6a74f07da7b50b53366ce1778e2938801be1acde Mon Sep 17 00:00:00 2001 From: Matthew Wolter Date: Sat, 11 Apr 2026 20:03:39 -0700 Subject: [PATCH 1/5] =?UTF-8?q?G2:=20F22=20=E2=80=94=20add=20Event.is=5Fer?= =?UTF-8?q?ror()=20helper=20and=20document=20.type-check=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: wait_for_event matches a single EventType; when callers pass [X, ERROR] to send() or wait_for_events, the return value may be an error response whose payload is {"reason": "..."} — not the command- specific keys the caller expects. Without a documented contract and a convenience helper, every call site independently forgets to check .type before accessing payload keys, leading to KeyError (F21/M01, M04) or silent fallthrough. The is_error() helper and docstrings on send()/wait_for_events() establish the contract that subsequent commits in this branch rely on. Refs: Forensics report finding F22 --- src/meshcore/commands/base.py | 21 +++++++++++++++++---- src/meshcore/events.py | 11 +++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/meshcore/commands/base.py b/src/meshcore/commands/base.py index 9e0f00e..a927882 100644 --- a/src/meshcore/commands/base.py +++ b/src/meshcore/commands/base.py @@ -90,6 +90,14 @@ class CommandHandlerBase: expected_events: Optional[Union[EventType, List[EventType]]] = None, timeout: Optional[float] = None, ) -> Event: + """Wait for the first of *expected_events* to arrive. + + Returns the first matched ``Event``. When ``EventType.ERROR`` is + among the expected types, the caller **must** check + ``result.is_error()`` before accessing command-specific payload + keys — an ERROR payload is ``{"reason": "..."}`` and will + ``KeyError`` on any other key. + """ try: # Convert single event to list if needed if not isinstance(expected_events, list): @@ -129,9 +137,6 @@ class CommandHandlerBase: logger.debug(f"Command error: {e}") return Event(EventType.ERROR, {"error": str(e)}) - return Event(EventType.ERROR, {}) - - async def send( self, data: bytes, @@ -151,7 +156,14 @@ class CommandHandlerBase: timeout: Timeout in seconds, or None to use default_timeout Returns: - Event: The full event object that was received in response to the command + Event: The full event object that was received in response to + the command. + + Important: + When ``EventType.ERROR`` is included in *expected_events*, the + returned event may be an error response. Callers **must** + check ``result.is_error()`` before accessing command-specific + payload keys to avoid ``KeyError``. """ if not self.dispatcher: raise RuntimeError("Dispatcher not set, cannot send commands") @@ -266,6 +278,7 @@ class CommandHandlerBase: contact = self._get_contact_by_prefix(dst_bytes.hex()) # need a contact for return path if contact is None: logger.error("No contact found") + return Event(EventType.ERROR, {"reason": "contact_not_found"}) zero_hop = False if contact["out_path_len"] == -1: diff --git a/src/meshcore/events.py b/src/meshcore/events.py index f8a7521..258dc7f 100644 --- a/src/meshcore/events.py +++ b/src/meshcore/events.py @@ -104,6 +104,17 @@ class Event: if kwargs: self.attributes.update(kwargs) + def is_error(self) -> bool: + """Return True if this event represents an error response. + + Callers that include ``EventType.ERROR`` in their expected-events + list **must** check ``result.is_error()`` (or ``result.type == + EventType.ERROR``) before accessing keyed payload fields, because + an ERROR payload contains ``{"reason": "..."}`` — not the + command-specific keys the caller expects on the happy path. + """ + return self.type == EventType.ERROR + def clone(self): """ Create a copy of the event. From 1e508a363610eb6555867dd9144bbf18e47dfb14 Mon Sep 17 00:00:00 2001 From: Matthew Wolter Date: Sat, 11 Apr 2026 20:03:49 -0700 Subject: [PATCH 2/5] =?UTF-8?q?G2:=20F21/M01=20=E2=80=94=20fix=20send=5Fms?= =?UTF-8?q?g=5Fwith=5Fretry=20fallthrough=20to=20KeyError=20on=20ERROR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: When send_msg() returned an ERROR event (e.g. firmware rejected the send), the error-check logged the failure but did not return or continue. Execution fell through to result.payload["expected_ack"], which raised KeyError because the ERROR payload is {"reason": "..."}. The retry loop — the entire purpose of this function — never ran. Now the ERROR path increments attempt counters and continues the loop, preserving the retry semantics the function name promises. Refs: Forensics report findings F21, M01 --- src/meshcore/commands/messaging.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/meshcore/commands/messaging.py b/src/meshcore/commands/messaging.py index b266ae0..b821ea0 100644 --- a/src/meshcore/commands/messaging.py +++ b/src/meshcore/commands/messaging.py @@ -144,8 +144,12 @@ class MessagingCommands(CommandHandlerBase): logger.info(f"Retry sending msg: {attempts + 1}") result = await self.send_msg(dst, msg, timestamp, attempt=attempts) - if result.type == EventType.ERROR: - logger.error(f"⚠️ Failed to send message: {result.payload}") + if result.is_error(): + logger.error(f"Failed to send message: {result.payload}") + attempts += 1 + if flood: + flood_attempts += 1 + continue exp_ack = result.payload["expected_ack"].hex() timeout = result.payload["suggested_timeout"] / 1000 * 1.2 if timeout==0 else timeout @@ -255,7 +259,7 @@ class MessagingCommands(CommandHandlerBase): elif path_hash_len == 8 : flags = 3 else : - logger.error(f"Invalid path format: {e}") + logger.error(f"Invalid path format: unknown path_hash_len {path_hash_len}") return Event(EventType.ERROR, {"reason": "invalid_path_format"}) else: flags = 0 From f2e294c36801cfcd9da7720021207a6561df8196 Mon Sep 17 00:00:00 2001 From: Matthew Wolter Date: Sat, 11 Apr 2026 20:04:00 -0700 Subject: [PATCH 3/5] =?UTF-8?q?G2:=20M02=20=E2=80=94=20add=20EventType.ERR?= =?UTF-8?q?OR=20to=20send=5Fappstart=20expected=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: send_appstart() only expected [SELF_INFO]. If firmware returned RESP_CODE_ERR (version mismatch, unsupported feature flag), wait_for_event never matched and the command hung until DEFAULT_TIMEOUT (5s) fired. Bootstrap is called on every initial connect, so a 5s hang on error was user-visible. Now expects [SELF_INFO, ERROR] so firmware errors are returned immediately as Event objects instead of timing out. Refs: Forensics report finding M02 --- src/meshcore/commands/device.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/meshcore/commands/device.py b/src/meshcore/commands/device.py index af986db..e5d2b5d 100644 --- a/src/meshcore/commands/device.py +++ b/src/meshcore/commands/device.py @@ -13,7 +13,7 @@ class DeviceCommands(CommandHandlerBase): async def send_appstart(self) -> Event: logger.debug("Sending appstart command") b1 = bytearray(b"\x01\x03 mccli") - return await self.send(b1, [EventType.SELF_INFO]) + return await self.send(b1, [EventType.SELF_INFO, EventType.ERROR]) async def send_device_query(self) -> Event: logger.debug("Sending device query command") @@ -129,32 +129,50 @@ class DeviceCommands(CommandHandlerBase): return await self.send(data, [EventType.OK, EventType.ERROR]) async def set_telemetry_mode_base(self, telemetry_mode_base: int) -> Event: - infos = (await self.send_appstart()).payload + result = await self.send_appstart() + if result.is_error(): + return result + infos = result.payload infos["telemetry_mode_base"] = telemetry_mode_base return await self.set_other_params_from_infos(infos) async def set_telemetry_mode_loc(self, telemetry_mode_loc: int) -> Event: - infos = (await self.send_appstart()).payload + result = await self.send_appstart() + if result.is_error(): + return result + infos = result.payload infos["telemetry_mode_loc"] = telemetry_mode_loc return await self.set_other_params_from_infos(infos) async def set_telemetry_mode_env(self, telemetry_mode_env: int) -> Event: - infos = (await self.send_appstart()).payload + result = await self.send_appstart() + if result.is_error(): + return result + infos = result.payload infos["telemetry_mode_env"] = telemetry_mode_env return await self.set_other_params_from_infos(infos) async def set_manual_add_contacts(self, manual_add_contacts: bool) -> Event: - infos = (await self.send_appstart()).payload + result = await self.send_appstart() + if result.is_error(): + return result + infos = result.payload infos["manual_add_contacts"] = manual_add_contacts return await self.set_other_params_from_infos(infos) async def set_advert_loc_policy(self, advert_loc_policy: int) -> Event: - infos = (await self.send_appstart()).payload + result = await self.send_appstart() + if result.is_error(): + return result + infos = result.payload infos["adv_loc_policy"] = advert_loc_policy return await self.set_other_params_from_infos(infos) async def set_multi_acks(self, multi_acks: int) -> Event: - infos = (await self.send_appstart()).payload + result = await self.send_appstart() + if result.is_error(): + return result + infos = result.payload infos["multi_acks"] = multi_acks return await self.set_other_params_from_infos(infos) From 729393358221d6e4e1c6d7ee6a9138ac80c436f8 Mon Sep 17 00:00:00 2001 From: Matthew Wolter Date: Sat, 11 Apr 2026 20:05:07 -0700 Subject: [PATCH 4/5] G2: add verification tests for F22, F21/M01, M02, M04, N06, F14 12 new tests in tests/unit/test_g2_error_handling.py covering all G2 findings: - test_g2_event_is_error_true/false (F22): is_error() helper works. - test_g2_send_msg_with_retry_error_no_keyerror (F21/M01): retry loop continues on ERROR instead of KeyError on missing expected_ack. - test_g2_send_appstart_returns_error (M02): ERROR event returned immediately instead of hanging until timeout. - test_g2_set_telemetry_mode_base/loc/env_error (M04): setters return ERROR instead of KeyError on appstart failure. - test_g2_set_manual_add_contacts/advert_loc_policy/multi_acks_error (M04): remaining three setters return ERROR cleanly. - test_g2_send_anon_req_contact_not_found (N06): returns ERROR instead of TypeError on NoneType subscript. - test_g2_send_trace_unknown_path_hash_len (F14): returns ERROR instead of NameError on undefined 'e'. Refs: Forensics report findings F22, F21, M01, M02, M04, N06, F14 --- tests/unit/test_g2_error_handling.py | 237 +++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 tests/unit/test_g2_error_handling.py diff --git a/tests/unit/test_g2_error_handling.py b/tests/unit/test_g2_error_handling.py new file mode 100644 index 0000000..4f83de2 --- /dev/null +++ b/tests/unit/test_g2_error_handling.py @@ -0,0 +1,237 @@ +"""Verification tests for G2 — error response handling fixes. + +Each test maps to a finding in proposal §4.3. The tests confirm that +error responses are surfaced cleanly instead of causing KeyError, +TypeError, NameError, or silent fallthrough. +""" +import asyncio +import pytest +from unittest.mock import MagicMock, AsyncMock, patch + +from meshcore.commands import CommandHandler +from meshcore.events import EventType, Event, Subscription + +pytestmark = pytest.mark.asyncio + +VALID_PUBKEY_HEX = "0123456789abcdef" * 4 # 64 hex chars = 32 bytes + + +# ── Fixtures ─────────────────────────────────────────────────────── + +@pytest.fixture +def mock_connection(): + connection = MagicMock() + connection.send = AsyncMock() + return connection + + +@pytest.fixture +def mock_dispatcher(): + dispatcher = MagicMock() + dispatcher.wait_for_event = AsyncMock() + dispatcher.dispatch = AsyncMock() + + def fake_subscribe(event_type, handler, attribute_filters=None): + sub = MagicMock(spec=Subscription) + sub.unsubscribe = MagicMock() + dispatcher._last_subscribe_handler = handler + dispatcher._last_subscribe_event_type = event_type + return sub + + dispatcher.subscribe = MagicMock(side_effect=fake_subscribe) + return dispatcher + + +@pytest.fixture +def command_handler(mock_connection, mock_dispatcher): + handler = CommandHandler() + + async def sender(data): + await mock_connection.send(data) + + handler._sender_func = sender + handler.dispatcher = mock_dispatcher + return handler + + +def setup_error_response(mock_dispatcher): + """Configure dispatcher to return an ERROR event for any subscribe.""" + def fake_subscribe(evt_type, handler, attr_filters=None): + sub = MagicMock(spec=Subscription) + sub.unsubscribe = MagicMock() + # Always fire ERROR regardless of which event type was subscribed + if evt_type == EventType.ERROR: + asyncio.get_event_loop().call_soon( + handler, Event(EventType.ERROR, {"reason": "test_error"}) + ) + return sub + + mock_dispatcher.subscribe = MagicMock(side_effect=fake_subscribe) + + +def setup_event_response(mock_dispatcher, event_type, payload): + """Configure dispatcher to return a specific event.""" + def fake_subscribe(evt_type, handler, attr_filters=None): + sub = MagicMock(spec=Subscription) + sub.unsubscribe = MagicMock() + if evt_type == event_type: + asyncio.get_event_loop().call_soon( + handler, Event(event_type, payload) + ) + return sub + + mock_dispatcher.subscribe = MagicMock(side_effect=fake_subscribe) + + +# ── F22: Event.is_error() helper ────────────────────────────────── + +async def test_g2_event_is_error_true(): + """F22: is_error() returns True for ERROR events.""" + event = Event(EventType.ERROR, {"reason": "test"}) + assert event.is_error() is True + + +async def test_g2_event_is_error_false(): + """F22: is_error() returns False for non-ERROR events.""" + event = Event(EventType.OK, {}) + assert event.is_error() is False + event2 = Event(EventType.SELF_INFO, {"name": "test"}) + assert event2.is_error() is False + + +# ── F21/M01: send_msg_with_retry continues on ERROR ────────────── + +async def test_g2_send_msg_with_retry_error_no_keyerror( + command_handler, mock_dispatcher +): + """F21/M01: send_msg_with_retry returns None (exhausted retries) on + persistent ERROR instead of raising KeyError on missing 'expected_ack'.""" + setup_error_response(mock_dispatcher) + + # Provide a mock contact so the path logic doesn't interfere + command_handler._get_contact_by_prefix = MagicMock(return_value=None) + + # max_attempts=2 so it retries once then gives up + result = await command_handler.send_msg_with_retry( + VALID_PUBKEY_HEX, "hello", max_attempts=2, timeout=0.1 + ) + + # Should return None (no ACK received) rather than raising KeyError + assert result is None + + +# ── M02: send_appstart includes ERROR in expected events ────────── + +async def test_g2_send_appstart_returns_error( + command_handler, mock_dispatcher +): + """M02: send_appstart returns ERROR event instead of hanging on timeout.""" + setup_error_response(mock_dispatcher) + + result = await command_handler.send_appstart() + + assert result.type == EventType.ERROR + assert result.is_error() is True + assert result.payload["reason"] == "test_error" + + +# ── M04: device setters return ERROR from send_appstart ─────────── + +async def test_g2_set_telemetry_mode_base_error( + command_handler, mock_dispatcher +): + """M04: set_telemetry_mode_base returns ERROR instead of KeyError.""" + setup_error_response(mock_dispatcher) + + result = await command_handler.set_telemetry_mode_base(1) + + assert result.is_error() + assert result.payload["reason"] == "test_error" + + +async def test_g2_set_telemetry_mode_loc_error( + command_handler, mock_dispatcher +): + """M04: set_telemetry_mode_loc returns ERROR instead of KeyError.""" + setup_error_response(mock_dispatcher) + + result = await command_handler.set_telemetry_mode_loc(1) + + assert result.is_error() + + +async def test_g2_set_telemetry_mode_env_error( + command_handler, mock_dispatcher +): + """M04: set_telemetry_mode_env returns ERROR instead of KeyError.""" + setup_error_response(mock_dispatcher) + + result = await command_handler.set_telemetry_mode_env(1) + + assert result.is_error() + + +async def test_g2_set_manual_add_contacts_error( + command_handler, mock_dispatcher +): + """M04: set_manual_add_contacts returns ERROR instead of KeyError.""" + setup_error_response(mock_dispatcher) + + result = await command_handler.set_manual_add_contacts(True) + + assert result.is_error() + + +async def test_g2_set_advert_loc_policy_error( + command_handler, mock_dispatcher +): + """M04: set_advert_loc_policy returns ERROR instead of KeyError.""" + setup_error_response(mock_dispatcher) + + result = await command_handler.set_advert_loc_policy(1) + + assert result.is_error() + + +async def test_g2_set_multi_acks_error( + command_handler, mock_dispatcher +): + """M04: set_multi_acks returns ERROR instead of KeyError.""" + setup_error_response(mock_dispatcher) + + result = await command_handler.set_multi_acks(1) + + assert result.is_error() + + +# ── N06: send_anon_req returns ERROR on contact not found ───────── + +async def test_g2_send_anon_req_contact_not_found( + command_handler, mock_dispatcher +): + """N06: send_anon_req returns ERROR event when contact prefix not found, + instead of raising TypeError on NoneType subscript.""" + command_handler._get_contact_by_prefix = MagicMock(return_value=None) + + result = await command_handler.send_anon_req( + VALID_PUBKEY_HEX, MagicMock(value=1) + ) + + assert result.is_error() + assert result.payload["reason"] == "contact_not_found" + + +# ── F14: send_trace handles unknown path_hash_len without NameError ── + +async def test_g2_send_trace_unknown_path_hash_len( + command_handler, mock_connection, mock_dispatcher +): + """F14: send_trace with a path whose segments don't match any known + path_hash_len returns ERROR cleanly instead of NameError on 'e'.""" + # 5-char hex segments → path_hash_len = 2.5 → doesn't match 1,2,4,8 + result = await command_handler.send_trace( + auth_code=0, tag=1, flags=None, path="abcde" + ) + + assert result.is_error() + assert result.payload["reason"] == "invalid_path_format" From 578ac36ccdb6e4fd3f3c44168f59104e8be101d5 Mon Sep 17 00:00:00 2001 From: Matthew Wolter Date: Sun, 12 Apr 2026 07:56:06 -0700 Subject: [PATCH 5/5] Remove internal references from error handling tests Rename test_g2_error_handling.py to test_error_handling.py. Strip G2 prefix from module docstring, _g2_ from function names, and finding IDs (F22, F21/M01, M02, M04, N06, F14) from docstrings and section comments. Proposal cross-references removed. --- ...ror_handling.py => test_error_handling.py} | 67 +++++++++---------- 1 file changed, 33 insertions(+), 34 deletions(-) rename tests/unit/{test_g2_error_handling.py => test_error_handling.py} (71%) diff --git a/tests/unit/test_g2_error_handling.py b/tests/unit/test_error_handling.py similarity index 71% rename from tests/unit/test_g2_error_handling.py rename to tests/unit/test_error_handling.py index 4f83de2..7f88e28 100644 --- a/tests/unit/test_g2_error_handling.py +++ b/tests/unit/test_error_handling.py @@ -1,8 +1,7 @@ -"""Verification tests for G2 — error response handling fixes. +"""Verification tests for error response handling fixes. -Each test maps to a finding in proposal §4.3. The tests confirm that -error responses are surfaced cleanly instead of causing KeyError, -TypeError, NameError, or silent fallthrough. +The tests confirm that error responses are surfaced cleanly instead +of causing KeyError, TypeError, NameError, or silent fallthrough. """ import asyncio import pytest @@ -83,28 +82,28 @@ def setup_event_response(mock_dispatcher, event_type, payload): mock_dispatcher.subscribe = MagicMock(side_effect=fake_subscribe) -# ── F22: Event.is_error() helper ────────────────────────────────── +# ── Event.is_error() helper ────────────────────────────────── -async def test_g2_event_is_error_true(): - """F22: is_error() returns True for ERROR events.""" +async def test_event_is_error_true(): + """is_error() returns True for ERROR events.""" event = Event(EventType.ERROR, {"reason": "test"}) assert event.is_error() is True -async def test_g2_event_is_error_false(): - """F22: is_error() returns False for non-ERROR events.""" +async def test_event_is_error_false(): + """is_error() returns False for non-ERROR events.""" event = Event(EventType.OK, {}) assert event.is_error() is False event2 = Event(EventType.SELF_INFO, {"name": "test"}) assert event2.is_error() is False -# ── F21/M01: send_msg_with_retry continues on ERROR ────────────── +# ── send_msg_with_retry continues on ERROR ────────────── -async def test_g2_send_msg_with_retry_error_no_keyerror( +async def test_send_msg_with_retry_error_no_keyerror( command_handler, mock_dispatcher ): - """F21/M01: send_msg_with_retry returns None (exhausted retries) on + """send_msg_with_retry returns None (exhausted retries) on persistent ERROR instead of raising KeyError on missing 'expected_ack'.""" setup_error_response(mock_dispatcher) @@ -120,12 +119,12 @@ async def test_g2_send_msg_with_retry_error_no_keyerror( assert result is None -# ── M02: send_appstart includes ERROR in expected events ────────── +# ── send_appstart includes ERROR in expected events ────────── -async def test_g2_send_appstart_returns_error( +async def test_send_appstart_returns_error( command_handler, mock_dispatcher ): - """M02: send_appstart returns ERROR event instead of hanging on timeout.""" + """send_appstart returns ERROR event instead of hanging on timeout.""" setup_error_response(mock_dispatcher) result = await command_handler.send_appstart() @@ -135,12 +134,12 @@ async def test_g2_send_appstart_returns_error( assert result.payload["reason"] == "test_error" -# ── M04: device setters return ERROR from send_appstart ─────────── +# ── device setters return ERROR from send_appstart ─────────── -async def test_g2_set_telemetry_mode_base_error( +async def test_set_telemetry_mode_base_error( command_handler, mock_dispatcher ): - """M04: set_telemetry_mode_base returns ERROR instead of KeyError.""" + """set_telemetry_mode_base returns ERROR instead of KeyError.""" setup_error_response(mock_dispatcher) result = await command_handler.set_telemetry_mode_base(1) @@ -149,10 +148,10 @@ async def test_g2_set_telemetry_mode_base_error( assert result.payload["reason"] == "test_error" -async def test_g2_set_telemetry_mode_loc_error( +async def test_set_telemetry_mode_loc_error( command_handler, mock_dispatcher ): - """M04: set_telemetry_mode_loc returns ERROR instead of KeyError.""" + """set_telemetry_mode_loc returns ERROR instead of KeyError.""" setup_error_response(mock_dispatcher) result = await command_handler.set_telemetry_mode_loc(1) @@ -160,10 +159,10 @@ async def test_g2_set_telemetry_mode_loc_error( assert result.is_error() -async def test_g2_set_telemetry_mode_env_error( +async def test_set_telemetry_mode_env_error( command_handler, mock_dispatcher ): - """M04: set_telemetry_mode_env returns ERROR instead of KeyError.""" + """set_telemetry_mode_env returns ERROR instead of KeyError.""" setup_error_response(mock_dispatcher) result = await command_handler.set_telemetry_mode_env(1) @@ -171,10 +170,10 @@ async def test_g2_set_telemetry_mode_env_error( assert result.is_error() -async def test_g2_set_manual_add_contacts_error( +async def test_set_manual_add_contacts_error( command_handler, mock_dispatcher ): - """M04: set_manual_add_contacts returns ERROR instead of KeyError.""" + """set_manual_add_contacts returns ERROR instead of KeyError.""" setup_error_response(mock_dispatcher) result = await command_handler.set_manual_add_contacts(True) @@ -182,10 +181,10 @@ async def test_g2_set_manual_add_contacts_error( assert result.is_error() -async def test_g2_set_advert_loc_policy_error( +async def test_set_advert_loc_policy_error( command_handler, mock_dispatcher ): - """M04: set_advert_loc_policy returns ERROR instead of KeyError.""" + """set_advert_loc_policy returns ERROR instead of KeyError.""" setup_error_response(mock_dispatcher) result = await command_handler.set_advert_loc_policy(1) @@ -193,10 +192,10 @@ async def test_g2_set_advert_loc_policy_error( assert result.is_error() -async def test_g2_set_multi_acks_error( +async def test_set_multi_acks_error( command_handler, mock_dispatcher ): - """M04: set_multi_acks returns ERROR instead of KeyError.""" + """set_multi_acks returns ERROR instead of KeyError.""" setup_error_response(mock_dispatcher) result = await command_handler.set_multi_acks(1) @@ -204,12 +203,12 @@ async def test_g2_set_multi_acks_error( assert result.is_error() -# ── N06: send_anon_req returns ERROR on contact not found ───────── +# ── send_anon_req returns ERROR on contact not found ───────── -async def test_g2_send_anon_req_contact_not_found( +async def test_send_anon_req_contact_not_found( command_handler, mock_dispatcher ): - """N06: send_anon_req returns ERROR event when contact prefix not found, + """send_anon_req returns ERROR event when contact prefix not found, instead of raising TypeError on NoneType subscript.""" command_handler._get_contact_by_prefix = MagicMock(return_value=None) @@ -221,12 +220,12 @@ async def test_g2_send_anon_req_contact_not_found( assert result.payload["reason"] == "contact_not_found" -# ── F14: send_trace handles unknown path_hash_len without NameError ── +# ── send_trace handles unknown path_hash_len without NameError ── -async def test_g2_send_trace_unknown_path_hash_len( +async def test_send_trace_unknown_path_hash_len( command_handler, mock_connection, mock_dispatcher ): - """F14: send_trace with a path whose segments don't match any known + """send_trace with a path whose segments don't match any known path_hash_len returns ERROR cleanly instead of NameError on 'e'.""" # 5-char hex segments → path_hash_len = 2.5 → doesn't match 1,2,4,8 result = await command_handler.send_trace(