Files
meshcore-bot/tests/test_message_handler.py
Stacy Olivas 2c4daa1720 test: expanded test suite for v0.9.0 modules
Command tests:
- tests/commands/: test_base_command, test_cmd_command, test_dice_command,
  test_hello_command, test_help_command, test_magic8_command,
  test_ping_command, test_roll_command
- tests/test_bridge_bot_responses, test_channel_manager_logic,
  test_checkin_service, test_command_manager, test_command_prefix,
  test_config_merge, test_config_validation, test_db_manager,
  test_plugin_loader, test_profanity_filter, test_security_utils,
  test_service_plugin_loader, test_utils

Integration and unit:
- tests/integration/: test_path_graph_integration, test_path_resolution
- tests/regression/: test_keyword_escapes
- tests/unit/: test_mesh_graph, test_mesh_graph_edges,
  test_mesh_graph_multihop, test_mesh_graph_optimizations,
  test_mesh_graph_scoring, test_mesh_graph_validation,
  test_path_command_graph, test_path_command_graph_selection,
  test_path_command_multibyte

Helpers: tests/conftest.py, tests/helpers.py
2026-03-17 17:45:21 -07:00

815 lines
33 KiB
Python

"""Tests for MessageHandler pure logic (no network, no meshcore device)."""
import configparser
import time
from unittest.mock import AsyncMock, Mock, patch
import pytest
from modules.message_handler import MessageHandler
from modules.models import MeshMessage
@pytest.fixture
def bot(mock_logger):
"""Minimal bot mock for MessageHandler instantiation."""
bot = Mock()
bot.logger = mock_logger
bot.config = configparser.ConfigParser()
bot.config.add_section("Bot")
bot.config.set("Bot", "enabled", "true")
bot.config.set("Bot", "rf_data_timeout", "15.0")
bot.config.set("Bot", "message_correlation_timeout", "10.0")
bot.config.set("Bot", "enable_enhanced_correlation", "true")
bot.config.add_section("Channels")
bot.config.set("Channels", "respond_to_dms", "true")
bot.connection_time = None
bot.prefix_hex_chars = 2
bot.command_manager = Mock()
bot.command_manager.monitor_channels = ["general", "test"]
bot.command_manager.is_user_banned = Mock(return_value=False)
bot.command_manager.commands = {}
return bot
@pytest.fixture
def handler(bot):
return MessageHandler(bot)
# ---------------------------------------------------------------------------
# _is_old_cached_message
# ---------------------------------------------------------------------------
class TestIsOldCachedMessage:
"""Tests for MessageHandler._is_old_cached_message()."""
def test_no_connection_time_returns_false(self, handler):
handler.bot.connection_time = None
assert handler._is_old_cached_message(12345) is False
def test_none_timestamp_returns_false(self, handler):
handler.bot.connection_time = time.time()
assert handler._is_old_cached_message(None) is False
def test_unknown_timestamp_returns_false(self, handler):
handler.bot.connection_time = time.time()
assert handler._is_old_cached_message("unknown") is False
def test_zero_timestamp_returns_false(self, handler):
handler.bot.connection_time = time.time()
assert handler._is_old_cached_message(0) is False
def test_negative_timestamp_returns_false(self, handler):
handler.bot.connection_time = time.time()
assert handler._is_old_cached_message(-1) is False
def test_old_timestamp_returns_true(self, handler):
now = time.time()
handler.bot.connection_time = now
old = now - 100 # 100 seconds before connection
assert handler._is_old_cached_message(old) is True
def test_recent_timestamp_returns_false(self, handler):
now = time.time()
handler.bot.connection_time = now
recent = now + 1 # after connection
assert handler._is_old_cached_message(recent) is False
def test_far_future_timestamp_returns_false(self, handler):
handler.bot.connection_time = time.time()
future = time.time() + 7200 # 2 hours in future
assert handler._is_old_cached_message(future) is False
def test_invalid_string_returns_false(self, handler):
handler.bot.connection_time = time.time()
assert handler._is_old_cached_message("not_a_number") is False
# ---------------------------------------------------------------------------
# _path_bytes_to_nodes
# ---------------------------------------------------------------------------
class TestPathBytesToNodes:
"""Tests for MessageHandler._path_bytes_to_nodes()."""
def test_single_byte_per_hop(self, handler):
# 3 bytes -> 3 nodes of 2 hex chars each
path_hex, nodes = handler._path_bytes_to_nodes(bytes.fromhex("017e86"), prefix_hex_chars=2)
assert path_hex == "017e86"
assert nodes == ["01", "7E", "86"]
def test_two_bytes_per_hop(self, handler):
path_hex, nodes = handler._path_bytes_to_nodes(bytes.fromhex("01027e86"), prefix_hex_chars=4)
assert nodes == ["0102", "7E86"]
def test_remainder_falls_back_to_1byte(self, handler):
# 3 bytes with prefix_hex_chars=4 → remainder, fallback to 1 byte
path_hex, nodes = handler._path_bytes_to_nodes(bytes.fromhex("017e86"), prefix_hex_chars=4)
assert nodes == ["01", "7E", "86"]
def test_empty_bytes(self, handler):
path_hex, nodes = handler._path_bytes_to_nodes(b"", prefix_hex_chars=2)
assert path_hex == ""
# Empty or fallback nodes — no crash expected
assert isinstance(nodes, list)
def test_zero_prefix_hex_chars_defaults_to_2(self, handler):
path_hex, nodes = handler._path_bytes_to_nodes(bytes.fromhex("017e"), prefix_hex_chars=0)
assert nodes == ["01", "7E"]
# ---------------------------------------------------------------------------
# _path_hex_to_nodes
# ---------------------------------------------------------------------------
class TestPathHexToNodes:
"""Tests for MessageHandler._path_hex_to_nodes()."""
def test_splits_into_2char_nodes(self, handler):
handler.bot.prefix_hex_chars = 2
nodes = handler._path_hex_to_nodes("017e86")
assert nodes == ["01", "7e", "86"]
def test_empty_string_returns_empty(self, handler):
nodes = handler._path_hex_to_nodes("")
assert nodes == []
def test_short_string_returns_empty(self, handler):
nodes = handler._path_hex_to_nodes("0")
assert nodes == []
def test_4char_prefix_hex_chars(self, handler):
handler.bot.prefix_hex_chars = 4
nodes = handler._path_hex_to_nodes("01027e86")
assert nodes == ["0102", "7e86"]
def test_remainder_falls_back_to_2chars(self, handler):
handler.bot.prefix_hex_chars = 4
# 6 hex chars (3 bytes) with 4-char chunks → remainder → fallback to 2-char
nodes = handler._path_hex_to_nodes("017e86")
assert nodes == ["01", "7e", "86"]
# ---------------------------------------------------------------------------
# _format_path_string
# ---------------------------------------------------------------------------
class TestFormatPathString:
"""Tests for MessageHandler._format_path_string()."""
def test_empty_path_returns_direct(self, handler):
assert handler._format_path_string("") == "Direct"
def test_legacy_single_byte_per_hop(self, handler):
result = handler._format_path_string("017e86")
assert result == "01,7e,86"
def test_with_bytes_per_hop_1(self, handler):
result = handler._format_path_string("017e86", bytes_per_hop=1)
assert result == "01,7e,86"
def test_with_bytes_per_hop_2(self, handler):
result = handler._format_path_string("01027e86", bytes_per_hop=2)
assert result == "0102,7e86"
def test_remainder_with_bytes_per_hop_falls_back(self, handler):
# 3 bytes (6 hex) with bytes_per_hop=2 → remainder → fallback to 1 byte
result = handler._format_path_string("017e86", bytes_per_hop=2)
assert result == "01,7e,86"
def test_none_path_returns_direct(self, handler):
assert handler._format_path_string(None) == "Direct"
def test_invalid_hex_returns_raw(self, handler):
result = handler._format_path_string("ZZZZ", bytes_per_hop=None)
# Should not crash; returns "Raw: ..." fallback
assert "Raw" in result or "ZZ" in result.upper() or result == "Direct"
# ---------------------------------------------------------------------------
# _get_route_type_name
# ---------------------------------------------------------------------------
class TestGetRouteTypeName:
"""Tests for MessageHandler._get_route_type_name()."""
def test_known_types(self, handler):
assert handler._get_route_type_name(0x00) == "ROUTE_TYPE_TRANSPORT_FLOOD"
assert handler._get_route_type_name(0x01) == "ROUTE_TYPE_FLOOD"
assert handler._get_route_type_name(0x02) == "ROUTE_TYPE_DIRECT"
assert handler._get_route_type_name(0x03) == "ROUTE_TYPE_TRANSPORT_DIRECT"
def test_unknown_type(self, handler):
result = handler._get_route_type_name(0xFF)
assert "UNKNOWN" in result
assert "ff" in result
# ---------------------------------------------------------------------------
# get_payload_type_name
# ---------------------------------------------------------------------------
class TestGetPayloadTypeName:
"""Tests for MessageHandler.get_payload_type_name()."""
def test_known_types(self, handler):
assert handler.get_payload_type_name(0x00) == "REQ"
assert handler.get_payload_type_name(0x02) == "TXT_MSG"
assert handler.get_payload_type_name(0x04) == "ADVERT"
assert handler.get_payload_type_name(0x05) == "GRP_TXT"
assert handler.get_payload_type_name(0x08) == "PATH"
assert handler.get_payload_type_name(0x0F) == "RAW_CUSTOM"
def test_unknown_type(self, handler):
result = handler.get_payload_type_name(0xAB)
assert "UNKNOWN" in result
# ---------------------------------------------------------------------------
# should_process_message
# ---------------------------------------------------------------------------
class TestShouldProcessMessage:
"""Tests for MessageHandler.should_process_message()."""
def _make_msg(self, channel=None, is_dm=False, sender_id="Alice"):
return MeshMessage(
content="hello",
channel=channel,
is_dm=is_dm,
sender_id=sender_id,
)
def test_bot_disabled_returns_false(self, handler):
handler.bot.config.set("Bot", "enabled", "false")
msg = self._make_msg(channel="general")
assert handler.should_process_message(msg) is False
def test_banned_user_returns_false(self, handler):
handler.bot.command_manager.is_user_banned.return_value = True
msg = self._make_msg(channel="general")
assert handler.should_process_message(msg) is False
def test_monitored_channel_returns_true(self, handler):
msg = self._make_msg(channel="general")
assert handler.should_process_message(msg) is True
def test_unmonitored_channel_returns_false(self, handler):
msg = self._make_msg(channel="unmonitored")
assert handler.should_process_message(msg) is False
def test_dm_enabled_returns_true(self, handler):
handler.bot.config.set("Channels", "respond_to_dms", "true")
msg = self._make_msg(is_dm=True)
assert handler.should_process_message(msg) is True
def test_dm_disabled_returns_false(self, handler):
handler.bot.config.set("Channels", "respond_to_dms", "false")
msg = self._make_msg(is_dm=True)
assert handler.should_process_message(msg) is False
def test_command_override_allows_unmonitored_channel(self, handler):
cmd = Mock()
cmd.is_channel_allowed = Mock(return_value=True)
handler.bot.command_manager.commands = {"special": cmd}
msg = self._make_msg(channel="unmonitored")
assert handler.should_process_message(msg) is True
# ---------------------------------------------------------------------------
# _cleanup_stale_cache_entries
# ---------------------------------------------------------------------------
class TestCleanupStaleCacheEntries:
"""Tests for MessageHandler._cleanup_stale_cache_entries()."""
def test_removes_old_timestamp_cache_entries(self, handler):
now = time.time()
current_time = now + handler._cache_cleanup_interval + 1
# Old entry: well outside rf_data_timeout relative to current_time
old_ts = current_time - handler.rf_data_timeout - 10
# Recent entry: within rf_data_timeout of current_time
recent_ts = current_time - 1
handler.rf_data_by_timestamp[old_ts] = {"timestamp": old_ts, "data": "old"}
handler.rf_data_by_timestamp[recent_ts] = {"timestamp": recent_ts, "data": "new"}
# Force full cleanup
handler._last_cache_cleanup = 0
handler._cleanup_stale_cache_entries(current_time=current_time)
# Old entry should be gone, recent kept
assert old_ts not in handler.rf_data_by_timestamp
assert recent_ts in handler.rf_data_by_timestamp
def test_removes_stale_pubkey_cache_entries(self, handler):
now = time.time()
handler.rf_data_by_pubkey["deadbeef"] = [
{"timestamp": now - 100, "data": "old"}, # stale
{"timestamp": now, "data": "new"}, # fresh
]
handler._last_cache_cleanup = 0
handler._cleanup_stale_cache_entries(current_time=now + handler._cache_cleanup_interval + 1)
entries = handler.rf_data_by_pubkey.get("deadbeef", [])
assert all(now - e["timestamp"] < handler.rf_data_timeout for e in entries)
def test_removes_stale_recent_rf_data(self, handler):
now = time.time()
handler.recent_rf_data = [
{"timestamp": now - 100},
{"timestamp": now},
]
handler._last_cache_cleanup = 0
handler._cleanup_stale_cache_entries(current_time=now + handler._cache_cleanup_interval + 1)
assert all(now - e["timestamp"] < handler.rf_data_timeout for e in handler.recent_rf_data)
def test_skips_full_cleanup_within_interval(self, handler):
now = time.time()
handler._last_cache_cleanup = now # just cleaned
# Stale entry in timestamp cache
stale_ts = now - 100
handler.rf_data_by_timestamp[stale_ts] = {"timestamp": stale_ts}
# Call with time just slightly after (within cleanup interval)
handler._cleanup_stale_cache_entries(current_time=now + 1)
# Still cleaned (timeout-only cleanup still runs)
assert stale_ts not in handler.rf_data_by_timestamp
# ---------------------------------------------------------------------------
# find_recent_rf_data
# ---------------------------------------------------------------------------
class TestFindRecentRfData:
"""Tests for MessageHandler.find_recent_rf_data()."""
def _rf_entry(self, age=0, packet_prefix="aabbccdd", pubkey_prefix="1122"):
return {
"timestamp": time.time() - age,
"snr": 5,
"rssi": -80,
"packet_prefix": packet_prefix,
"pubkey_prefix": pubkey_prefix,
}
def test_returns_none_when_empty(self, handler):
handler.recent_rf_data = []
assert handler.find_recent_rf_data() is None
def test_returns_none_when_all_too_old(self, handler):
handler.rf_data_timeout = 5
handler.recent_rf_data = [self._rf_entry(age=100)]
assert handler.find_recent_rf_data() is None
def test_returns_most_recent_fallback(self, handler):
handler.rf_data_timeout = 30
entry = self._rf_entry(age=1)
handler.recent_rf_data = [entry]
result = handler.find_recent_rf_data()
assert result is entry
def test_exact_packet_prefix_match(self, handler):
handler.rf_data_timeout = 30
target = self._rf_entry(age=1, packet_prefix="deadbeefdeadbeef1234567890abcdef")
other = self._rf_entry(age=2, packet_prefix="00000000000000000000000000000000")
handler.recent_rf_data = [target, other]
result = handler.find_recent_rf_data("deadbeefdeadbeef1234567890abcdef")
assert result is target
def test_exact_pubkey_prefix_match(self, handler):
handler.rf_data_timeout = 30
target = self._rf_entry(age=1, pubkey_prefix="abcd", packet_prefix="")
other = self._rf_entry(age=2, pubkey_prefix="1111", packet_prefix="")
handler.recent_rf_data = [target, other]
result = handler.find_recent_rf_data("abcd")
assert result is target
def test_partial_packet_prefix_match(self, handler):
handler.rf_data_timeout = 30
long_prefix = "aabbccddeeff0011aabbccddeeff0011"
partial_key = "aabbccddeeff0011" + "xxxxxxxxxxxxxxxx"
target = self._rf_entry(age=1, packet_prefix=long_prefix, pubkey_prefix="")
handler.recent_rf_data = [target]
result = handler.find_recent_rf_data(partial_key)
assert result is target
def test_no_key_returns_most_recent(self, handler):
handler.rf_data_timeout = 30
old = self._rf_entry(age=10)
new = self._rf_entry(age=1)
handler.recent_rf_data = [old, new]
result = handler.find_recent_rf_data()
assert result["timestamp"] == new["timestamp"]
def test_custom_max_age(self, handler):
handler.rf_data_timeout = 30
entry = self._rf_entry(age=20)
handler.recent_rf_data = [entry]
# With max_age=5, entry is too old
assert handler.find_recent_rf_data(max_age_seconds=5) is None
# With max_age=30, entry is visible
assert handler.find_recent_rf_data(max_age_seconds=30) is entry
# ---------------------------------------------------------------------------
# handle_raw_data
# ---------------------------------------------------------------------------
class TestHandleRawData:
"""Tests for MessageHandler.handle_raw_data()."""
def _make_event(self, payload):
event = Mock()
event.payload = payload
return event
async def test_no_payload_logs_warning(self, handler):
event = Mock(spec=[])
handler.logger = Mock()
await handler.handle_raw_data(event)
handler.logger.warning.assert_called()
async def test_payload_none_logs_warning(self, handler):
event = Mock()
event.payload = None
handler.logger = Mock()
await handler.handle_raw_data(event)
handler.logger.warning.assert_called()
async def test_payload_without_data_field_logs_warning(self, handler):
event = self._make_event({"other": "stuff"})
handler.logger = Mock()
with patch.object(handler, "decode_meshcore_packet", return_value=None):
await handler.handle_raw_data(event)
handler.logger.warning.assert_called()
async def test_payload_with_hex_data_calls_decode(self, handler):
event = self._make_event({"data": "aabbccdd"})
handler.logger = Mock()
with patch.object(handler, "decode_meshcore_packet", return_value=None) as mock_decode:
await handler.handle_raw_data(event)
mock_decode.assert_called_once_with("aabbccdd")
async def test_payload_strips_0x_prefix(self, handler):
event = self._make_event({"data": "0xaabbccdd"})
handler.logger = Mock()
with patch.object(handler, "decode_meshcore_packet", return_value=None) as mock_decode:
await handler.handle_raw_data(event)
mock_decode.assert_called_once_with("aabbccdd")
async def test_decoded_packet_calls_process_advertisement(self, handler):
event = self._make_event({"data": "aabbccdd"})
handler.logger = Mock()
packet_info = {"type": "adv", "node_id": "ab"}
with patch.object(handler, "decode_meshcore_packet", return_value=packet_info):
with patch.object(handler, "_process_advertisement_packet", new_callable=AsyncMock) as mock_adv:
await handler.handle_raw_data(event)
mock_adv.assert_called_once_with(packet_info, None)
async def test_non_string_data_logs_warning(self, handler):
event = self._make_event({"data": 12345})
handler.logger = Mock()
await handler.handle_raw_data(event)
handler.logger.warning.assert_called()
async def test_exception_does_not_raise(self, handler):
event = self._make_event({"data": "aabb"})
handler.logger = Mock()
with patch.object(handler, "decode_meshcore_packet", side_effect=RuntimeError("oops")):
# Should not raise
await handler.handle_raw_data(event)
handler.logger.error.assert_called()
# ---------------------------------------------------------------------------
# handle_contact_message
# ---------------------------------------------------------------------------
class TestHandleContactMessage:
"""Tests for MessageHandler.handle_contact_message()."""
def _make_event(self, payload):
event = Mock()
event.payload = payload
event.metadata = {}
return event
def _setup_handler(self, handler):
handler.logger = Mock()
handler.bot.meshcore = Mock()
handler.bot.meshcore.contacts = {}
handler.bot.translator = None
async def test_no_payload_returns_early(self, handler):
self._setup_handler(handler)
event = Mock(spec=[])
await handler.handle_contact_message(event)
handler.logger.warning.assert_called()
async def test_payload_none_returns_early(self, handler):
self._setup_handler(handler)
event = Mock()
event.payload = None
await handler.handle_contact_message(event)
handler.logger.warning.assert_called()
async def test_old_cached_message_not_processed(self, handler):
self._setup_handler(handler)
# Set connection_time in the future relative to an old timestamp
handler.bot.connection_time = time.time()
old_ts = int(time.time()) - 3600 # 1 hour old
event = self._make_event({
"pubkey_prefix": "ab12",
"text": "hello",
"path_len": 255,
"sender_timestamp": old_ts,
})
with patch.object(handler, "process_message", new_callable=AsyncMock) as mock_pm:
with patch.object(handler, "_debug_decode_message_path", new_callable=AsyncMock):
with patch.object(handler, "_debug_decode_packet_for_message", new_callable=AsyncMock):
await handler.handle_contact_message(event)
mock_pm.assert_not_called()
async def test_new_message_calls_process_message(self, handler):
self._setup_handler(handler)
handler.bot.connection_time = None # No connection time = don't filter
event = self._make_event({
"pubkey_prefix": "ab12",
"text": "hello",
"path_len": 255,
"sender_timestamp": int(time.time()),
})
with patch.object(handler, "process_message", new_callable=AsyncMock) as mock_pm:
with patch.object(handler, "_debug_decode_message_path", new_callable=AsyncMock):
with patch.object(handler, "_debug_decode_packet_for_message", new_callable=AsyncMock):
await handler.handle_contact_message(event)
mock_pm.assert_called_once()
async def test_snr_from_payload(self, handler):
self._setup_handler(handler)
handler.bot.connection_time = None
captured = {}
async def capture_message(msg):
captured["msg"] = msg
event = self._make_event({
"pubkey_prefix": "ab12",
"text": "hello",
"path_len": 255,
"sender_timestamp": int(time.time()),
"SNR": 7,
"RSSI": -70,
})
with patch.object(handler, "process_message", side_effect=capture_message):
with patch.object(handler, "_debug_decode_message_path", new_callable=AsyncMock):
with patch.object(handler, "_debug_decode_packet_for_message", new_callable=AsyncMock):
await handler.handle_contact_message(event)
assert captured["msg"].snr == 7
assert captured["msg"].rssi == -70
async def test_direct_path_len_255(self, handler):
self._setup_handler(handler)
handler.bot.connection_time = None
captured = {}
async def capture_message(msg):
captured["msg"] = msg
event = self._make_event({
"pubkey_prefix": "ab12",
"text": "hi",
"path_len": 255,
"sender_timestamp": int(time.time()),
})
with patch.object(handler, "process_message", side_effect=capture_message):
with patch.object(handler, "_debug_decode_message_path", new_callable=AsyncMock):
with patch.object(handler, "_debug_decode_packet_for_message", new_callable=AsyncMock):
await handler.handle_contact_message(event)
assert captured["msg"].is_dm is True
async def test_message_is_dm(self, handler):
self._setup_handler(handler)
handler.bot.connection_time = None
captured = {}
async def capture_message(msg):
captured["msg"] = msg
event = self._make_event({
"pubkey_prefix": "ab12",
"text": "dm text",
"path_len": 0,
"sender_timestamp": int(time.time()),
})
with patch.object(handler, "process_message", side_effect=capture_message):
with patch.object(handler, "_debug_decode_message_path", new_callable=AsyncMock):
with patch.object(handler, "_debug_decode_packet_for_message", new_callable=AsyncMock):
await handler.handle_contact_message(event)
assert captured["msg"].is_dm is True
assert captured["msg"].content == "dm text"
async def test_contact_name_lookup(self, handler):
self._setup_handler(handler)
handler.bot.connection_time = None
handler.bot.meshcore.contacts = {
"key1": {
"public_key": "ab12deadbeef",
"name": "Alice",
"out_path": "",
"out_path_len": 0,
}
}
captured = {}
async def capture_message(msg):
captured["msg"] = msg
event = self._make_event({
"pubkey_prefix": "ab12",
"text": "hi",
"path_len": 255,
"sender_timestamp": int(time.time()),
})
with patch.object(handler, "process_message", side_effect=capture_message):
with patch.object(handler, "_debug_decode_message_path", new_callable=AsyncMock):
with patch.object(handler, "_debug_decode_packet_for_message", new_callable=AsyncMock):
await handler.handle_contact_message(event)
assert captured["msg"].sender_id == "Alice"
async def test_exception_does_not_propagate(self, handler):
self._setup_handler(handler)
event = self._make_event({
"pubkey_prefix": "ab12",
"text": "hello",
"path_len": 255,
"sender_timestamp": int(time.time()),
})
with patch.object(handler, "_debug_decode_message_path", side_effect=RuntimeError("boom")):
# Should not raise
await handler.handle_contact_message(event)
handler.logger.error.assert_called()
# ---------------------------------------------------------------------------
# handle_channel_message
# ---------------------------------------------------------------------------
class TestHandleChannelMessage:
"""Tests for MessageHandler.handle_channel_message()."""
def _setup_handler(self, handler):
handler.logger = Mock()
handler.bot.meshcore = Mock()
handler.bot.meshcore.contacts = {}
handler.bot.channel_manager = Mock()
handler.bot.channel_manager.get_channel_name = Mock(return_value="general")
handler.bot.translator = None
handler.bot.mesh_graph = None
handler.recent_rf_data = []
handler.enhanced_correlation = False
def _make_event(self, payload):
event = Mock()
event.payload = payload
return event
async def test_no_payload_returns_early(self, handler):
self._setup_handler(handler)
event = Mock(spec=[])
await handler.handle_channel_message(event)
handler.logger.warning.assert_called()
async def test_payload_none_returns_early(self, handler):
self._setup_handler(handler)
event = Mock()
event.payload = None
await handler.handle_channel_message(event)
handler.logger.warning.assert_called()
async def test_basic_channel_message_calls_process_message(self, handler):
self._setup_handler(handler)
handler.bot.connection_time = None
event = self._make_event({
"channel_idx": 0,
"text": "ALICE: hello world",
"path_len": 255,
"sender_timestamp": int(time.time()),
})
with patch.object(handler, "process_message", new_callable=AsyncMock) as mock_pm:
with patch.object(handler, "_debug_decode_message_path", new_callable=AsyncMock):
with patch.object(handler, "_debug_decode_packet_for_message", new_callable=AsyncMock):
await handler.handle_channel_message(event)
mock_pm.assert_called_once()
async def test_sender_extracted_from_text(self, handler):
self._setup_handler(handler)
handler.bot.connection_time = None
captured = {}
async def capture(msg):
captured["msg"] = msg
event = self._make_event({
"channel_idx": 0,
"text": "BOB: hi there",
"path_len": 0,
"sender_timestamp": int(time.time()),
})
with patch.object(handler, "process_message", side_effect=capture):
with patch.object(handler, "_debug_decode_message_path", new_callable=AsyncMock):
with patch.object(handler, "_debug_decode_packet_for_message", new_callable=AsyncMock):
await handler.handle_channel_message(event)
assert captured["msg"].sender_id == "BOB"
assert captured["msg"].content == "hi there"
async def test_text_without_colon_uses_full_text(self, handler):
self._setup_handler(handler)
handler.bot.connection_time = None
captured = {}
async def capture(msg):
captured["msg"] = msg
event = self._make_event({
"channel_idx": 0,
"text": "no colon here",
"path_len": 0,
"sender_timestamp": int(time.time()),
})
with patch.object(handler, "process_message", side_effect=capture):
with patch.object(handler, "_debug_decode_message_path", new_callable=AsyncMock):
with patch.object(handler, "_debug_decode_packet_for_message", new_callable=AsyncMock):
await handler.handle_channel_message(event)
assert captured["msg"].content == "no colon here"
async def test_old_cached_message_not_processed(self, handler):
self._setup_handler(handler)
handler.bot.connection_time = time.time()
old_ts = int(time.time()) - 3600
event = self._make_event({
"channel_idx": 0,
"text": "CAROL: old msg",
"path_len": 0,
"sender_timestamp": old_ts,
})
with patch.object(handler, "process_message", new_callable=AsyncMock) as mock_pm:
with patch.object(handler, "_debug_decode_message_path", new_callable=AsyncMock):
with patch.object(handler, "_debug_decode_packet_for_message", new_callable=AsyncMock):
await handler.handle_channel_message(event)
mock_pm.assert_not_called()
async def test_snr_from_payload(self, handler):
self._setup_handler(handler)
handler.bot.connection_time = None
captured = {}
async def capture(msg):
captured["msg"] = msg
event = self._make_event({
"channel_idx": 0,
"text": "DAN: test",
"path_len": 0,
"sender_timestamp": int(time.time()),
"SNR": 9,
"RSSI": -85,
})
with patch.object(handler, "process_message", side_effect=capture):
with patch.object(handler, "_debug_decode_message_path", new_callable=AsyncMock):
with patch.object(handler, "_debug_decode_packet_for_message", new_callable=AsyncMock):
await handler.handle_channel_message(event)
assert captured["msg"].snr == 9
assert captured["msg"].rssi == -85
async def test_channel_name_set_on_message(self, handler):
self._setup_handler(handler)
handler.bot.connection_time = None
handler.bot.channel_manager.get_channel_name = Mock(return_value="emergency")
captured = {}
async def capture(msg):
captured["msg"] = msg
event = self._make_event({
"channel_idx": 2,
"text": "EVE: help",
"path_len": 0,
"sender_timestamp": int(time.time()),
})
with patch.object(handler, "process_message", side_effect=capture):
with patch.object(handler, "_debug_decode_message_path", new_callable=AsyncMock):
with patch.object(handler, "_debug_decode_packet_for_message", new_callable=AsyncMock):
await handler.handle_channel_message(event)
assert captured["msg"].channel == "emergency"
assert captured["msg"].is_dm is False
async def test_exception_does_not_propagate(self, handler):
self._setup_handler(handler)
event = self._make_event({
"channel_idx": 0,
"text": "FRANK: crash",
"path_len": 0,
"sender_timestamp": int(time.time()),
})
with patch.object(handler, "_debug_decode_message_path", side_effect=RuntimeError("boom")):
await handler.handle_channel_message(event)
handler.logger.error.assert_called()