mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-04-15 11:55:38 +00:00
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
815 lines
33 KiB
Python
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()
|