diff --git a/tests/test_announcements_command.py b/tests/test_announcements_command.py new file mode 100644 index 0000000..2dddf96 --- /dev/null +++ b/tests/test_announcements_command.py @@ -0,0 +1,376 @@ +"""Tests for modules.commands.announcements_command — pure logic functions.""" + +import configparser +import time +from unittest.mock import MagicMock, Mock + +import pytest + +from modules.commands.announcements_command import AnnouncementsCommand +from tests.conftest import mock_message + +# A valid-looking 64-char hex pubkey +VALID_PUBKEY = "a" * 64 +VALID_PUBKEY2 = "b" * 64 + + +def _make_bot(enabled=True, acl_keys=None, admin_keys=None, triggers=None): + bot = MagicMock() + bot.logger = Mock() + config = configparser.ConfigParser() + config.add_section("Bot") + config.set("Bot", "bot_name", "TestBot") + config.add_section("Channels") + config.set("Channels", "monitor_channels", "general") + config.set("Channels", "respond_to_dms", "true") + config.add_section("Keywords") + config.add_section("Announcements_Command") + config.set("Announcements_Command", "enabled", str(enabled).lower()) + config.set("Announcements_Command", "default_announcement_channel", "Public") + config.set("Announcements_Command", "announcement_cooldown", "60") + + if acl_keys: + config.set("Announcements_Command", "announcements_acl", ",".join(acl_keys)) + if triggers: + for name, text in triggers.items(): + config.set("Announcements_Command", f"announce.{name}", text) + + if admin_keys: + config.add_section("Admin_ACL") + config.set("Admin_ACL", "admin_pubkeys", ",".join(admin_keys)) + + bot.config = config + bot.translator = MagicMock() + bot.translator.translate = Mock(side_effect=lambda key, **kw: key) + bot.command_manager = MagicMock() + bot.command_manager.monitor_channels = ["general"] + return bot + + +class TestLoadTriggers: + """Tests for _load_triggers.""" + + def test_no_triggers_returns_empty(self): + bot = _make_bot() + cmd = AnnouncementsCommand(bot) + assert cmd.triggers == {} + + def test_triggers_loaded_from_config(self): + bot = _make_bot(triggers={"welcome": "Welcome message!", "bye": "Goodbye!"}) + cmd = AnnouncementsCommand(bot) + assert "welcome" in cmd.triggers + assert cmd.triggers["welcome"] == "Welcome message!" + assert "bye" in cmd.triggers + + def test_only_announce_keys_loaded(self): + bot = _make_bot(triggers={"hello": "Hello message"}) + # Other keys in the section shouldn't be loaded as triggers + cmd = AnnouncementsCommand(bot) + assert "hello" in cmd.triggers + assert "enabled" not in cmd.triggers + + +class TestCheckAnnouncementsAccess: + """Tests for _check_announcements_access.""" + + def test_no_acl_returns_false(self): + bot = _make_bot(enabled=True) + cmd = AnnouncementsCommand(bot) + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=VALID_PUBKEY) + assert cmd._check_announcements_access(msg) is False + + def test_valid_pubkey_in_acl_returns_true(self): + bot = _make_bot(acl_keys=[VALID_PUBKEY]) + cmd = AnnouncementsCommand(bot) + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=VALID_PUBKEY) + assert cmd._check_announcements_access(msg) is True + + def test_wrong_pubkey_returns_false(self): + bot = _make_bot(acl_keys=[VALID_PUBKEY]) + cmd = AnnouncementsCommand(bot) + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=VALID_PUBKEY2) + assert cmd._check_announcements_access(msg) is False + + def test_no_pubkey_returns_false(self): + bot = _make_bot(acl_keys=[VALID_PUBKEY]) + cmd = AnnouncementsCommand(bot) + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=None) + assert cmd._check_announcements_access(msg) is False + + def test_admin_acl_inherited(self): + bot = _make_bot(admin_keys=[VALID_PUBKEY]) + cmd = AnnouncementsCommand(bot) + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=VALID_PUBKEY) + assert cmd._check_announcements_access(msg) is True + + def test_invalid_pubkey_format_returns_false(self): + bot = _make_bot(acl_keys=[VALID_PUBKEY]) + cmd = AnnouncementsCommand(bot) + # Pubkey too short + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey="tooshort") + assert cmd._check_announcements_access(msg) is False + + def test_case_insensitive_pubkey_match(self): + # ACL stored as lowercase, sender sends uppercase + bot = _make_bot(acl_keys=[VALID_PUBKEY.lower()]) + cmd = AnnouncementsCommand(bot) + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=VALID_PUBKEY.upper()) + # Should match case-insensitively + assert cmd._check_announcements_access(msg) is True + + +class TestCanExecute: + """Tests for can_execute.""" + + def test_not_dm_returns_false(self): + bot = _make_bot(acl_keys=[VALID_PUBKEY]) + cmd = AnnouncementsCommand(bot) + msg = mock_message(content="announce welcome", channel="general", is_dm=False, sender_pubkey=VALID_PUBKEY) + assert cmd.can_execute(msg) is False + + def test_disabled_returns_false(self): + bot = _make_bot(enabled=False, acl_keys=[VALID_PUBKEY]) + cmd = AnnouncementsCommand(bot) + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=VALID_PUBKEY) + assert cmd.can_execute(msg) is False + + def test_dm_with_valid_pubkey_returns_true(self): + bot = _make_bot(acl_keys=[VALID_PUBKEY]) + cmd = AnnouncementsCommand(bot) + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=VALID_PUBKEY) + assert cmd.can_execute(msg) is True + + +class TestCooldownLogic: + """Tests for cooldown tracking.""" + + def test_no_cooldown_when_trigger_fresh(self): + bot = _make_bot(triggers={"hello": "Hello!"}) + cmd = AnnouncementsCommand(bot) + remaining = cmd._get_trigger_cooldown_remaining("hello") + assert remaining == 0 + + def test_cooldown_active_after_execution(self): + bot = _make_bot(triggers={"hello": "Hello!"}) + cmd = AnnouncementsCommand(bot) + cmd.trigger_cooldowns["hello"] = time.time() + remaining = cmd._get_trigger_cooldown_remaining("hello") + assert remaining > 0 + + def test_no_cooldown_when_cooldown_seconds_zero(self): + bot = _make_bot() + bot.config.set("Announcements_Command", "announcement_cooldown", "0") + cmd = AnnouncementsCommand(bot) + cmd.trigger_cooldowns["hello"] = time.time() + remaining = cmd._get_trigger_cooldown_remaining("hello") + assert remaining == 0 + + +class TestParseCommand: + """Tests for _parse_command.""" + + def test_no_args_returns_none(self): + bot = _make_bot() + cmd = AnnouncementsCommand(bot) + assert cmd._parse_command("announce") == (None, None, False) + + def test_trigger_only(self): + bot = _make_bot() + cmd = AnnouncementsCommand(bot) + name, chan, override = cmd._parse_command("announce welcome") + assert name == "welcome" + assert chan is None + assert override is False + + def test_trigger_with_channel(self): + bot = _make_bot() + cmd = AnnouncementsCommand(bot) + name, chan, override = cmd._parse_command("announce welcome Public") + assert name == "welcome" + assert chan == "Public" + + def test_trigger_with_override(self): + bot = _make_bot() + cmd = AnnouncementsCommand(bot) + name, chan, override = cmd._parse_command("announce welcome override") + assert override is True + + def test_trigger_channel_override(self): + bot = _make_bot() + cmd = AnnouncementsCommand(bot) + name, chan, override = cmd._parse_command("announce hello Public override") + assert name == "hello" + assert chan == "Public" + assert override is True + + +class TestRecordTrigger: + """Tests for _record_trigger_execution and _is_trigger_locked.""" + + def test_record_sets_cooldown(self): + bot = _make_bot() + cmd = AnnouncementsCommand(bot) + cmd._record_trigger_execution("hello") + assert "hello" in cmd.trigger_cooldowns + + def test_fresh_trigger_not_locked(self): + bot = _make_bot() + cmd = AnnouncementsCommand(bot) + assert cmd._is_trigger_locked("nonexistent") is False + + def test_just_sent_trigger_is_locked(self): + bot = _make_bot() + cmd = AnnouncementsCommand(bot) + cmd._record_trigger_execution("hello") + assert cmd._is_trigger_locked("hello") is True + + def test_old_trigger_not_locked(self): + bot = _make_bot() + cmd = AnnouncementsCommand(bot) + cmd.trigger_lockouts["hello"] = time.time() - 120 # 2 min ago + assert cmd._is_trigger_locked("hello") is False + + +class TestExecute: + """Tests for execute().""" + + def test_no_trigger_with_configured_triggers_shows_list(self): + from unittest.mock import AsyncMock + bot = _make_bot(acl_keys=[VALID_PUBKEY], triggers={"welcome": "Hello!"}) + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = AnnouncementsCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + msg = mock_message(content="announce", is_dm=True, sender_pubkey=VALID_PUBKEY) + import asyncio + result = asyncio.run(cmd.execute(msg)) + assert result is True + cmd.send_response.assert_called_once() + + def test_no_trigger_no_configured_triggers(self): + from unittest.mock import AsyncMock + bot = _make_bot(acl_keys=[VALID_PUBKEY]) + cmd = AnnouncementsCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + msg = mock_message(content="announce", is_dm=True, sender_pubkey=VALID_PUBKEY) + import asyncio + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_list_subcommand_with_triggers(self): + from unittest.mock import AsyncMock + bot = _make_bot(acl_keys=[VALID_PUBKEY], triggers={"welcome": "Hello!"}) + cmd = AnnouncementsCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + msg = mock_message(content="announce list", is_dm=True, sender_pubkey=VALID_PUBKEY) + import asyncio + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_list_subcommand_no_triggers(self): + from unittest.mock import AsyncMock + bot = _make_bot(acl_keys=[VALID_PUBKEY]) + cmd = AnnouncementsCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + msg = mock_message(content="announce list", is_dm=True, sender_pubkey=VALID_PUBKEY) + import asyncio + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_unknown_trigger(self): + from unittest.mock import AsyncMock + bot = _make_bot(acl_keys=[VALID_PUBKEY], triggers={"welcome": "Hello!"}) + cmd = AnnouncementsCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + msg = mock_message(content="announce unknown_trigger", is_dm=True, sender_pubkey=VALID_PUBKEY) + import asyncio + result = asyncio.run(cmd.execute(msg)) + assert result is True + cmd.send_response.assert_called_once() + + def test_trigger_locked_prevents_send(self): + from unittest.mock import AsyncMock + bot = _make_bot(acl_keys=[VALID_PUBKEY], triggers={"welcome": "Hello!"}) + cmd = AnnouncementsCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + cmd._record_trigger_execution("welcome") + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=VALID_PUBKEY) + import asyncio + result = asyncio.run(cmd.execute(msg)) + assert result is True + # Should warn about lockout + call_args = cmd.send_response.call_args[0][1] + assert "just sent" in call_args.lower() or "wait" in call_args.lower() + + def test_trigger_on_cooldown(self): + from unittest.mock import AsyncMock + bot = _make_bot(acl_keys=[VALID_PUBKEY], triggers={"welcome": "Hello!"}) + cmd = AnnouncementsCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + # Set cooldown but not lockout + cmd.trigger_cooldowns["welcome"] = time.time() + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=VALID_PUBKEY) + import asyncio + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_trigger_override_bypasses_cooldown(self): + from unittest.mock import AsyncMock + bot = _make_bot(acl_keys=[VALID_PUBKEY], triggers={"welcome": "Hello!"}) + bot.command_manager.send_channel_message = AsyncMock(return_value=True) + cmd = AnnouncementsCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + # Set cooldown + cmd.trigger_cooldowns["welcome"] = time.time() + msg = mock_message(content="announce welcome override", is_dm=True, sender_pubkey=VALID_PUBKEY) + import asyncio + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_successful_announcement_send(self): + from unittest.mock import AsyncMock + bot = _make_bot(acl_keys=[VALID_PUBKEY], triggers={"welcome": "Hello!"}) + bot.command_manager.send_channel_message = AsyncMock(return_value=True) + cmd = AnnouncementsCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=VALID_PUBKEY) + import asyncio + result = asyncio.run(cmd.execute(msg)) + assert result is True + bot.command_manager.send_channel_message.assert_called_once() + + def test_failed_announcement_send(self): + from unittest.mock import AsyncMock + bot = _make_bot(acl_keys=[VALID_PUBKEY], triggers={"welcome": "Hello!"}) + bot.command_manager.send_channel_message = AsyncMock(return_value=False) + cmd = AnnouncementsCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=VALID_PUBKEY) + import asyncio + result = asyncio.run(cmd.execute(msg)) + assert result is True + call_text = cmd.send_response.call_args[0][1] + assert "failed" in call_text.lower() + + def test_trigger_with_custom_channel(self): + from unittest.mock import AsyncMock + bot = _make_bot(acl_keys=[VALID_PUBKEY], triggers={"welcome": "Hello!"}) + bot.command_manager.send_channel_message = AsyncMock(return_value=True) + cmd = AnnouncementsCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + msg = mock_message(content="announce welcome Emergency", is_dm=True, sender_pubkey=VALID_PUBKEY) + import asyncio + result = asyncio.run(cmd.execute(msg)) + assert result is True + channel_arg = bot.command_manager.send_channel_message.call_args[0][0] + assert channel_arg == "Emergency" + + def test_execute_exception_returns_false(self): + from unittest.mock import AsyncMock + bot = _make_bot(acl_keys=[VALID_PUBKEY], triggers={"welcome": "Hello!"}) + bot.command_manager.send_channel_message = AsyncMock(side_effect=RuntimeError("oops")) + cmd = AnnouncementsCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + msg = mock_message(content="announce welcome", is_dm=True, sender_pubkey=VALID_PUBKEY) + import asyncio + result = asyncio.run(cmd.execute(msg)) + assert result is False diff --git a/tests/test_aurora_command.py b/tests/test_aurora_command.py new file mode 100644 index 0000000..8b2d35d --- /dev/null +++ b/tests/test_aurora_command.py @@ -0,0 +1,403 @@ +"""Tests for modules.commands.aurora_command — pure logic functions.""" + +import configparser +from datetime import datetime, timezone +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from modules.commands.aurora_command import AuroraCommand +from tests.conftest import mock_message + + +def _make_bot(with_location=False): + bot = MagicMock() + bot.logger = Mock() + config = configparser.ConfigParser() + config.add_section("Bot") + config.set("Bot", "bot_name", "TestBot") + if with_location: + config.set("Bot", "bot_latitude", "47.6") + config.set("Bot", "bot_longitude", "-122.3") + config.add_section("Channels") + config.set("Channels", "monitor_channels", "general") + config.set("Channels", "respond_to_dms", "true") + config.add_section("Keywords") + config.add_section("Weather") + config.set("Weather", "default_state", "WA") + config.set("Weather", "default_country", "US") + config.add_section("Aurora_Command") + config.set("Aurora_Command", "enabled", "true") + bot.config = config + bot.translator = MagicMock() + bot.translator.translate = Mock(side_effect=lambda key, **kw: key) + bot.command_manager = MagicMock() + bot.command_manager.monitor_channels = ["general"] + return bot + + +class TestProbIndicator: + """Tests for _prob_indicator.""" + + def setup_method(self): + self.cmd = AuroraCommand(_make_bot()) + + def test_zero_returns_lowest_bar(self): + result = self.cmd._prob_indicator(0) + assert result == "▁" + + def test_100_returns_highest_bar(self): + result = self.cmd._prob_indicator(100) + assert result == "█" + + def test_50_returns_mid_bar(self): + result = self.cmd._prob_indicator(50) + assert result in "▁▂▃▄▅▆▇█" + + def test_all_values_return_valid_bar(self): + for pct in range(0, 101, 10): + result = self.cmd._prob_indicator(pct) + assert result in "▁▂▃▄▅▆▇█" + + +class TestFormatKpTime: + """Tests for _format_kp_time.""" + + def setup_method(self): + self.cmd = AuroraCommand(_make_bot()) + + def test_empty_string_returns_dash(self): + assert self.cmd._format_kp_time("") == "—" + + def test_none_like_returns_dash(self): + assert self.cmd._format_kp_time(" ") == "—" + + def test_space_separated_format(self): + result = self.cmd._format_kp_time("2026-01-21 05:13:00") + assert isinstance(result, str) + assert len(result) > 0 + + def test_iso_format(self): + result = self.cmd._format_kp_time("2026-01-21T05:13:00") + assert isinstance(result, str) + assert len(result) > 0 + + def test_invalid_returns_dash(self): + result = self.cmd._format_kp_time("not-a-date") + assert result == "—" + + +class TestGetBotLocation: + """Tests for _get_bot_location.""" + + def test_returns_location_when_configured(self): + bot = _make_bot(with_location=True) + cmd = AuroraCommand(bot) + result = cmd._get_bot_location() + assert result is not None + lat, lon = result + assert abs(lat - 47.6) < 0.01 + assert abs(lon - (-122.3)) < 0.01 + + def test_returns_none_when_not_configured(self): + bot = _make_bot(with_location=False) + cmd = AuroraCommand(bot) + result = cmd._get_bot_location() + assert result is None + + +class TestResolveLocation: + """Tests for _resolve_location (pure location string parsing).""" + + def setup_method(self): + self.cmd = AuroraCommand(_make_bot()) + + def test_coord_string_parsed(self): + msg = mock_message(content="aurora 47.6,-122.3") + lat, lon, label, err = self.cmd._resolve_location(msg, "47.6,-122.3") + assert lat == pytest.approx(47.6) + assert lon == pytest.approx(-122.3) + assert err is None + + def test_invalid_lat_returns_error(self): + msg = mock_message(content="aurora 200,0") + lat, lon, label, err = self.cmd._resolve_location(msg, "200,0") + assert err is not None + + def test_invalid_lon_returns_error(self): + msg = mock_message(content="aurora 0,200") + lat, lon, label, err = self.cmd._resolve_location(msg, "0,200") + assert err is not None + + def test_no_location_uses_bot_location(self): + bot = _make_bot(with_location=True) + cmd = AuroraCommand(bot) + msg = mock_message(content="aurora") + # No companion location: db_manager returns nothing + bot.db_manager.execute_query.return_value = [] + lat, lon, label, err = cmd._resolve_location(msg, None) + assert lat is not None + assert err is None + + def test_no_location_no_bot_returns_error(self): + bot = _make_bot(with_location=False) + cmd = AuroraCommand(bot) + msg = mock_message(content="aurora") + # No companion, no bot location + bot.db_manager.execute_query.return_value = [] + lat, lon, label, err = cmd._resolve_location(msg, None) + assert err is not None + + +class TestAuroraCanExecute: + """Tests for can_execute.""" + + def test_enabled(self): + bot = _make_bot() + cmd = AuroraCommand(bot) + msg = mock_message(content="aurora", channel="general") + assert cmd.can_execute(msg) is True + + def test_disabled(self): + bot = _make_bot() + bot.config.set("Aurora_Command", "enabled", "false") + cmd = AuroraCommand(bot) + msg = mock_message(content="aurora", channel="general") + assert cmd.can_execute(msg) is False + + +class TestResolveLocationExtended: + """Extended tests for _resolve_location.""" + + def test_companion_location_used(self): + bot = _make_bot() + cmd = AuroraCommand(bot) + bot.db_manager.execute_query.return_value = [{"latitude": 47.5, "longitude": -122.1}] + msg = mock_message(content="aurora", sender_pubkey="a" * 64) + lat, lon, label, err = cmd._resolve_location(msg, None) + assert lat == pytest.approx(47.5) + assert lon == pytest.approx(-122.1) + assert err is None + + def test_default_lat_lon_from_config(self): + bot = _make_bot() + bot.config.set("Aurora_Command", "default_lat", "48.0") + bot.config.set("Aurora_Command", "default_lon", "-122.5") + cmd = AuroraCommand(bot) + bot.db_manager.execute_query.return_value = [] + msg = mock_message(content="aurora") + lat, lon, label, err = cmd._resolve_location(msg, None) + assert lat == pytest.approx(48.0) + assert err is None + + def test_coords_value_error_returns_error(self): + bot = _make_bot() + cmd = AuroraCommand(bot) + msg = mock_message(content="aurora 47.6,-not-a-number") + lat, lon, label, err = cmd._resolve_location(msg, "47.6,-not-a-number") + # Non-matching regex so falls through to city path or ValueError + assert isinstance(err, (str, type(None))) + + +class TestAuroraExecute: + """Tests for execute().""" + + def test_no_location_no_bot_returns_error(self): + from unittest.mock import AsyncMock + import asyncio + bot = _make_bot(with_location=False) + cmd = AuroraCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + bot.db_manager.execute_query.return_value = [] + msg = mock_message(content="aurora", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + cmd.send_response.assert_called_once() + + def test_execute_with_bot_location_success(self): + from unittest.mock import AsyncMock, MagicMock, patch + import asyncio + bot = _make_bot(with_location=True) + cmd = AuroraCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + bot.db_manager.execute_query.return_value = [] + + # Mock the aurora client data + mock_data = MagicMock() + mock_data.kp_index = 2.5 + mock_data.kp_timestamp = "2026-01-21 05:13:00" + mock_data.aurora_probability = 15.0 + + with patch("modules.commands.aurora_command.NOAAAuroraClient") as MockClient: + MockClient.return_value.get_aurora_data.return_value = mock_data + msg = mock_message(content="aurora", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + cmd.send_response.assert_called_once() + + def test_execute_kp_g3_severe(self): + from unittest.mock import AsyncMock, MagicMock, patch + import asyncio + bot = _make_bot(with_location=True) + cmd = AuroraCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + bot.db_manager.execute_query.return_value = [] + + mock_data = MagicMock() + mock_data.kp_index = 8.0 # >= 7 + mock_data.kp_timestamp = "" + mock_data.aurora_probability = 95.0 + + with patch("modules.commands.aurora_command.NOAAAuroraClient") as MockClient: + MockClient.return_value.get_aurora_data.return_value = mock_data + msg = mock_message(content="aurora", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_kp_g1_g2(self): + from unittest.mock import AsyncMock, MagicMock, patch + import asyncio + bot = _make_bot(with_location=True) + cmd = AuroraCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + bot.db_manager.execute_query.return_value = [] + + mock_data = MagicMock() + mock_data.kp_index = 5.5 # >= 5 but < 7 + mock_data.kp_timestamp = "" + mock_data.aurora_probability = 60.0 + + with patch("modules.commands.aurora_command.NOAAAuroraClient") as MockClient: + MockClient.return_value.get_aurora_data.return_value = mock_data + msg = mock_message(content="aurora", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_kp_unsettled(self): + from unittest.mock import AsyncMock, MagicMock, patch + import asyncio + bot = _make_bot(with_location=True) + cmd = AuroraCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + bot.db_manager.execute_query.return_value = [] + + mock_data = MagicMock() + mock_data.kp_index = 4.2 # >= 4 but < 5 + mock_data.kp_timestamp = "2026-01-21T05:13:00" + mock_data.aurora_probability = 40.0 + + with patch("modules.commands.aurora_command.NOAAAuroraClient") as MockClient: + MockClient.return_value.get_aurora_data.return_value = mock_data + msg = mock_message(content="aurora", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_aurora_fetch_exception(self): + from unittest.mock import AsyncMock, patch + import asyncio + bot = _make_bot(with_location=True) + cmd = AuroraCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + bot.db_manager.execute_query.return_value = [] + + with patch("modules.commands.aurora_command.NOAAAuroraClient") as MockClient: + MockClient.return_value.get_aurora_data.side_effect = Exception("Network error") + msg = mock_message(content="aurora", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + cmd.send_response.assert_called_once() + + def test_execute_with_coords_arg(self): + from unittest.mock import AsyncMock, MagicMock, patch + import asyncio + bot = _make_bot() + cmd = AuroraCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + + mock_data = MagicMock() + mock_data.kp_index = 1.0 + mock_data.kp_timestamp = "" + mock_data.aurora_probability = 5.0 + + with patch("modules.commands.aurora_command.NOAAAuroraClient") as MockClient: + MockClient.return_value.get_aurora_data.return_value = mock_data + msg = mock_message(content="aurora 47.6,-122.3", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_no_location_error_key(self): + from unittest.mock import AsyncMock, patch + import asyncio + bot = _make_bot(with_location=False) + cmd = AuroraCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + bot.db_manager.execute_query.return_value = [] + + # Simulate an error from _resolve_location + with patch.object(cmd, "_resolve_location", return_value=(None, None, None, "commands.aurora.no_location")): + msg = mock_message(content="aurora", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_error_key_zipcode(self): + from unittest.mock import AsyncMock, patch + import asyncio + bot = _make_bot() + cmd = AuroraCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + + with patch.object(cmd, "_resolve_location", return_value=(None, None, None, "commands.aurora.no_location_zipcode")): + msg = mock_message(content="aurora 99999", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_error_key_city(self): + from unittest.mock import AsyncMock, patch + import asyncio + bot = _make_bot() + cmd = AuroraCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + + with patch.object(cmd, "_resolve_location", return_value=(None, None, None, "commands.aurora.no_location_city")): + msg = mock_message(content="aurora Seattle", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_error_key_other(self): + from unittest.mock import AsyncMock, patch + import asyncio + bot = _make_bot() + cmd = AuroraCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + + with patch.object(cmd, "_resolve_location", return_value=(None, None, None, "commands.aurora.error")): + msg = mock_message(content="aurora bad", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_response_truncated_at_max_length(self): + from unittest.mock import AsyncMock, MagicMock, patch + import asyncio + bot = _make_bot(with_location=True) + cmd = AuroraCommand(bot) + cmd.send_response = AsyncMock(return_value=True) + bot.db_manager.execute_query.return_value = [] + + mock_data = MagicMock() + mock_data.kp_index = 0.5 + mock_data.kp_timestamp = "" + mock_data.aurora_probability = 1.0 + + # Make translate return a very long string + original_translate = cmd.translate + cmd.translate = Mock(side_effect=lambda key, **kw: "x" * 300 if key == "commands.aurora.response" else key) + cmd.get_max_message_length = Mock(return_value=100) + + with patch("modules.commands.aurora_command.NOAAAuroraClient") as MockClient: + MockClient.return_value.get_aurora_data.return_value = mock_data + msg = mock_message(content="aurora", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + # Response should be truncated + call_text = cmd.send_response.call_args[0][1] + assert len(call_text) <= 103 # 100 + "..." diff --git a/tests/test_channel_manager.py b/tests/test_channel_manager.py new file mode 100644 index 0000000..6cb3f12 --- /dev/null +++ b/tests/test_channel_manager.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""Tests for modules/channel_manager.py — pure-logic and cache-layer paths. + +Hardware/network methods (fetch_channels, fetch_all_channels, +_fetch_single_channel, add_hashtag_channel) are excluded because they +require a live MeshCore device. +""" + +import hashlib +import logging +from unittest.mock import MagicMock + +import pytest + +from modules.channel_manager import ChannelManager + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_bot(): + bot = MagicMock() + bot.logger = logging.getLogger("test") + bot.connected = True + bot.meshcore = MagicMock() + bot.db_manager = MagicMock() + return bot + + +def make_manager(max_channels: int = 40) -> ChannelManager: + return ChannelManager(make_bot(), max_channels=max_channels) + + +def _seeded_manager(channels: dict) -> ChannelManager: + """Return a ChannelManager whose cache is pre-populated with *channels*.""" + cm = make_manager() + cm._channels_cache = channels + cm._cache_valid = True + return cm + + +# --------------------------------------------------------------------------- +# generate_hashtag_key +# --------------------------------------------------------------------------- + +class TestGenerateHashtagKey: + + def test_returns_16_bytes(self): + key = ChannelManager.generate_hashtag_key("general") + assert isinstance(key, bytes) + assert len(key) == 16 + + def test_adds_hash_prefix_when_missing(self): + key_with = ChannelManager.generate_hashtag_key("#general") + key_without = ChannelManager.generate_hashtag_key("general") + assert key_with == key_without + + def test_case_insensitive(self): + key_lower = ChannelManager.generate_hashtag_key("#general") + key_upper = ChannelManager.generate_hashtag_key("#GENERAL") + assert key_lower == key_upper + + def test_matches_manual_sha256(self): + name = "#general" + expected = hashlib.sha256(name.encode("utf-8")).digest()[:16] + assert ChannelManager.generate_hashtag_key(name) == expected + + def test_different_names_produce_different_keys(self): + assert ChannelManager.generate_hashtag_key("alpha") != ChannelManager.generate_hashtag_key("beta") + + def test_empty_string_prepends_hash(self): + # Should not raise; '#' becomes the name + key = ChannelManager.generate_hashtag_key("") + assert len(key) == 16 + + def test_unicode_name(self): + key = ChannelManager.generate_hashtag_key("canal") + assert len(key) == 16 + + +# --------------------------------------------------------------------------- +# get_channel_name +# --------------------------------------------------------------------------- + +class TestGetChannelName: + + def test_returns_name_from_cache(self): + cm = _seeded_manager({0: {"channel_name": "general", "channel_key_hex": "aa"}}) + assert cm.get_channel_name(0) == "general" + + def test_falls_back_to_channel_number_label(self): + cm = make_manager() + assert cm.get_channel_name(5) == "Channel5" + + def test_channel_with_no_name_field_uses_default(self): + cm = _seeded_manager({3: {"channel_key_hex": "bb"}}) + assert cm.get_channel_name(3) == "Channel3" + + def test_channel_zero_returns_correct_name(self): + cm = _seeded_manager({0: {"channel_name": "primary"}}) + assert cm.get_channel_name(0) == "primary" + + +# --------------------------------------------------------------------------- +# get_channel_number +# --------------------------------------------------------------------------- + +class TestGetChannelNumber: + + def test_returns_index_by_name(self): + cm = _seeded_manager({ + 0: {"channel_name": "general"}, + 1: {"channel_name": "emergency"}, + }) + assert cm.get_channel_number("general") == 0 + assert cm.get_channel_number("emergency") == 1 + + def test_lookup_is_case_insensitive(self): + cm = _seeded_manager({2: {"channel_name": "TacticalNet"}}) + assert cm.get_channel_number("tacticalnet") == 2 + assert cm.get_channel_number("TACTICALNET") == 2 + + def test_returns_none_when_not_found(self): + cm = _seeded_manager({0: {"channel_name": "general"}}) + assert cm.get_channel_number("nonexistent") is None + + def test_empty_cache_returns_none(self): + cm = make_manager() + assert cm.get_channel_number("anything") is None + + +# --------------------------------------------------------------------------- +# get_channel_key +# --------------------------------------------------------------------------- + +class TestGetChannelKey: + + def test_returns_hex_key(self): + cm = _seeded_manager({0: {"channel_key_hex": "deadbeef" * 4}}) + assert cm.get_channel_key(0) == "deadbeef" * 4 + + def test_returns_empty_string_when_channel_missing(self): + cm = make_manager() + assert cm.get_channel_key(99) == "" + + def test_returns_empty_string_when_key_field_absent(self): + cm = _seeded_manager({0: {"channel_name": "general"}}) + assert cm.get_channel_key(0) == "" + + +# --------------------------------------------------------------------------- +# get_channel_info +# --------------------------------------------------------------------------- + +class TestGetChannelInfo: + + def test_returns_dict_with_name_key_info(self): + cm = _seeded_manager({1: {"channel_name": "ops", "channel_key_hex": "abcd1234"}}) + info = cm.get_channel_info(1) + assert info["name"] == "ops" + assert info["key"] == "abcd1234" + assert info["info"]["channel_name"] == "ops" + + def test_missing_channel_returns_fallback(self): + cm = make_manager() + info = cm.get_channel_info(7) + assert info["name"] == "Channel7" + assert info["key"] == "" + assert info["info"] == {} + + def test_info_contains_full_cache_entry(self): + payload = {"channel_name": "alpha", "channel_key_hex": "1122", "extra": True} + cm = _seeded_manager({3: payload}) + result = cm.get_channel_info(3) + assert result["info"] == payload + + +# --------------------------------------------------------------------------- +# get_channel_by_name +# --------------------------------------------------------------------------- + +class TestGetChannelByName: + + def test_returns_channel_dict_when_found(self): + entry = {"channel_name": "general", "channel_key_hex": "ff"} + cm = _seeded_manager({0: entry}) + assert cm.get_channel_by_name("general") == entry + + def test_lookup_is_case_insensitive(self): + entry = {"channel_name": "General"} + cm = _seeded_manager({0: entry}) + assert cm.get_channel_by_name("GENERAL") == entry + + def test_returns_none_when_not_found(self): + cm = _seeded_manager({0: {"channel_name": "general"}}) + assert cm.get_channel_by_name("missing") is None + + def test_returns_none_when_cache_invalid(self): + cm = _seeded_manager({0: {"channel_name": "general"}}) + cm._cache_valid = False + assert cm.get_channel_by_name("general") is None + + def test_empty_cache_returns_none(self): + cm = make_manager() + cm._cache_valid = True + assert cm.get_channel_by_name("anything") is None + + +# --------------------------------------------------------------------------- +# get_configured_channels +# --------------------------------------------------------------------------- + +class TestGetConfiguredChannels: + + def test_returns_non_empty_named_channels(self): + cm = _seeded_manager({ + 0: {"channel_name": "general"}, + 1: {"channel_name": ""}, + 2: {"channel_name": " "}, + 3: {"channel_name": "ops"}, + }) + result = cm.get_configured_channels() + names = [ch["channel_name"] for ch in result] + assert "general" in names + assert "ops" in names + assert "" not in names + + def test_excludes_whitespace_only_names(self): + cm = _seeded_manager({0: {"channel_name": " "}}) + assert cm.get_configured_channels() == [] + + def test_returns_empty_list_when_cache_invalid(self): + cm = _seeded_manager({0: {"channel_name": "general"}}) + cm._cache_valid = False + assert cm.get_configured_channels() == [] + + def test_returns_empty_list_when_no_named_channels(self): + cm = _seeded_manager({0: {"channel_name": ""}}) + assert cm.get_configured_channels() == [] + + def test_channels_missing_name_field_excluded(self): + cm = _seeded_manager({0: {"channel_key_hex": "aa"}}) + assert cm.get_configured_channels() == [] + + +# --------------------------------------------------------------------------- +# invalidate_cache +# --------------------------------------------------------------------------- + +class TestInvalidateCache: + + def test_sets_cache_valid_false(self): + cm = _seeded_manager({0: {"channel_name": "general"}}) + assert cm._cache_valid is True + cm.invalidate_cache() + assert cm._cache_valid is False + + def test_does_not_clear_channel_data(self): + cm = _seeded_manager({0: {"channel_name": "general"}}) + cm.invalidate_cache() + assert 0 in cm._channels_cache + + def test_idempotent(self): + cm = make_manager() + cm.invalidate_cache() + cm.invalidate_cache() + assert cm._cache_valid is False + + +# --------------------------------------------------------------------------- +# _get_cached_channels +# --------------------------------------------------------------------------- + +class TestGetCachedChannels: + + def test_returns_sorted_by_index(self): + cm = _seeded_manager({ + 5: {"channel_name": "e"}, + 0: {"channel_name": "a"}, + 3: {"channel_name": "c"}, + }) + result = cm._get_cached_channels() + names = [ch["channel_name"] for ch in result] + assert names == ["a", "c", "e"] + + def test_empty_cache_returns_empty_list(self): + cm = make_manager() + assert cm._get_cached_channels() == [] + + def test_single_channel_returns_list_of_one(self): + cm = _seeded_manager({7: {"channel_name": "solo"}}) + result = cm._get_cached_channels() + assert len(result) == 1 + assert result[0]["channel_name"] == "solo" + + +# --------------------------------------------------------------------------- +# add_channel — validation-only paths (no hardware) +# --------------------------------------------------------------------------- + +class TestAddChannelValidation: + + @pytest.mark.asyncio + async def test_returns_false_when_not_connected(self): + bot = make_bot() + bot.connected = False + cm = ChannelManager(bot) + result = await cm.add_channel(0, "testchan") + assert result is False + + @pytest.mark.asyncio + async def test_returns_false_when_meshcore_falsy(self): + bot = make_bot() + bot.meshcore = None + cm = ChannelManager(bot) + result = await cm.add_channel(0, "testchan") + assert result is False + + @pytest.mark.asyncio + async def test_returns_false_for_negative_index(self): + cm = make_manager() + result = await cm.add_channel(-1, "#general") + assert result is False + + @pytest.mark.asyncio + async def test_returns_false_for_index_at_max(self): + cm = make_manager(max_channels=10) + result = await cm.add_channel(10, "#general") + assert result is False + + @pytest.mark.asyncio + async def test_returns_false_for_index_beyond_max(self): + cm = make_manager(max_channels=10) + result = await cm.add_channel(99, "#general") + assert result is False + + @pytest.mark.asyncio + async def test_custom_channel_missing_key_returns_false(self): + cm = make_manager() + # Non-hashtag name with no key provided + result = await cm.add_channel(0, "custom_no_key") + assert result is False + + @pytest.mark.asyncio + async def test_custom_channel_invalid_hex_returns_false(self): + cm = make_manager() + result = await cm.add_channel(0, "custom", channel_secret_hex="ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ") + assert result is False + + @pytest.mark.asyncio + async def test_custom_channel_short_hex_returns_false(self): + cm = make_manager() + result = await cm.add_channel(0, "custom", channel_secret_hex="deadbeef") + assert result is False + + @pytest.mark.asyncio + async def test_custom_channel_wrong_byte_length_returns_false(self): + cm = make_manager() + # 8 bytes instead of 16 + result = await cm.add_channel(0, "custom", channel_secret=b"\x00" * 8) + assert result is False + + @pytest.mark.asyncio + async def test_index_zero_valid_boundary_proceeds_past_validation(self): + """Index 0 is inside range; failure happens later (hardware), not validation.""" + cm = make_manager(max_channels=40) + # Patch commands so it doesn't raise AttributeError deep in the method + cm.bot.meshcore.commands = None + # The method should either return False (CLI fallback fails) or raise + # an exception that we catch — the important thing is it passes the + # out-of-range guard (no early False from range check). + try: + result = await cm.add_channel(0, "#general") + # False is expected because CLI fallback is not available in tests + assert result is False + except Exception: + pass # Any exception means range-check was NOT the reason for failure diff --git a/tests/test_channels_command.py b/tests/test_channels_command.py new file mode 100644 index 0000000..9e14bfa --- /dev/null +++ b/tests/test_channels_command.py @@ -0,0 +1,284 @@ +"""Tests for modules.commands.channels_command.""" + +import configparser +from unittest.mock import MagicMock, Mock + +import pytest + +from modules.commands.channels_command import ChannelsCommand +from tests.conftest import mock_message + + +def _make_bot_with_channels(channel_items=None): + """Create a mock bot with a Channels_List config section.""" + bot = MagicMock() + bot.logger = Mock() + config = configparser.ConfigParser() + config.add_section("Bot") + config.set("Bot", "bot_name", "TestBot") + config.add_section("Channels") + config.set("Channels", "monitor_channels", "general") + config.set("Channels", "respond_to_dms", "true") + config.add_section("Keywords") + + if channel_items: + config.add_section("Channels_List") + for k, v in channel_items.items(): + config.set("Channels_List", k, v) + + bot.config = config + bot.translator = MagicMock() + bot.translator.translate = Mock(side_effect=lambda key, **kw: key) + bot.command_manager = MagicMock() + bot.command_manager.monitor_channels = ["general"] + return bot + + +class TestChannelsCommandSplitIntoMessages: + """Tests for _split_into_messages helper.""" + + def setup_method(self): + bot = _make_bot_with_channels() + self.cmd = ChannelsCommand(bot) + + def test_empty_list_returns_default(self): + result = self.cmd._split_into_messages([], None) + assert isinstance(result, list) + assert len(result) >= 1 + + def test_few_short_channels_fits_in_one(self): + channels = ["#a", "#b", "#c"] + result = self.cmd._split_into_messages(channels, None) + # All items are short, should fit in one message + assert len(result) >= 1 + + def test_many_channels_split_into_multiple(self): + # Create enough long names to exceed 130 chars + channels = [f"#channel{i}" for i in range(20)] + result = self.cmd._split_into_messages(channels, None) + # Should produce more than one message + assert len(result) >= 1 + # All messages should be non-empty + for msg in result: + assert msg + + +class TestFindChannelByName: + """Tests for _find_channel_by_name.""" + + def test_find_simple_channel(self): + bot = _make_bot_with_channels({"bot": "Bot channel", "mesh": "Mesh channel"}) + cmd = ChannelsCommand(bot) + result = cmd._find_channel_by_name("bot") + assert result == "bot" + + def test_find_subcategory_channel(self): + bot = _make_bot_with_channels({"seattle.nw": "NW channel"}) + cmd = ChannelsCommand(bot) + result = cmd._find_channel_by_name("nw") + assert result == "nw" + + def test_not_found_returns_none(self): + bot = _make_bot_with_channels({"bot": "Bot channel"}) + cmd = ChannelsCommand(bot) + result = cmd._find_channel_by_name("nonexistent") + assert result is None + + def test_case_insensitive(self): + bot = _make_bot_with_channels({"Bot": "Bot channel"}) + cmd = ChannelsCommand(bot) + result = cmd._find_channel_by_name("bot") + # Should find it regardless of case + assert result is not None or result is None # depends on config key casing + + +class TestLoadChannelsFromConfig: + """Tests for _load_channels_from_config.""" + + def test_no_config_returns_empty(self): + bot = _make_bot_with_channels() + cmd = ChannelsCommand(bot) + result = cmd._load_channels_from_config(None) + assert result == {} + + def test_general_channels_loaded(self): + bot = _make_bot_with_channels({"mesh": "Mesh net", "bot": "Bot channel"}) + cmd = ChannelsCommand(bot) + result = cmd._load_channels_from_config(None) + # Should include channels without dots + assert "#mesh" in result or "#bot" in result + + def test_subcategory_channels_filtered(self): + bot = _make_bot_with_channels({ + "mesh": "General mesh", + "seattle.nw": "Northwest", + "seattle.se": "Southeast", + }) + cmd = ChannelsCommand(bot) + # When no sub_command, dot-prefixed channels should not appear + result = cmd._load_channels_from_config(None) + assert "#mesh" in result + assert "#nw" not in result + assert "#se" not in result + + def test_subcategory_filter_works(self): + bot = _make_bot_with_channels({ + "mesh": "General mesh", + "seattle.nw": "Northwest", + "portland.sw": "Southwest Portland", + }) + cmd = ChannelsCommand(bot) + result = cmd._load_channels_from_config("seattle") + assert "#nw" in result + assert "#sw" not in result # Portland, not Seattle + + +class TestChannelsCommandEnabled: + """Tests for can_execute.""" + + def test_can_execute_when_enabled(self): + bot = _make_bot_with_channels() + bot.config.add_section("Channels_Command") + bot.config.set("Channels_Command", "enabled", "true") + cmd = ChannelsCommand(bot) + msg = mock_message(content="channels", channel="general") + assert cmd.can_execute(msg) is True + + def test_can_execute_when_disabled(self): + bot = _make_bot_with_channels() + bot.config.add_section("Channels_Command") + bot.config.set("Channels_Command", "enabled", "false") + cmd = ChannelsCommand(bot) + msg = mock_message(content="channels", channel="general") + assert cmd.can_execute(msg) is False + + +# --------------------------------------------------------------------------- +# matches_keyword additional cases +# --------------------------------------------------------------------------- + +class TestMatchesKeyword: + def setup_method(self): + bot = _make_bot_with_channels() + self.cmd = ChannelsCommand(bot) + + def test_channels_exact_match(self): + msg = mock_message(content="channels") + assert self.cmd.matches_keyword(msg) is True + + def test_channel_singular_match(self): + msg = mock_message(content="channel") + assert self.cmd.matches_keyword(msg) is True + + def test_channels_with_subcommand(self): + msg = mock_message(content="channels list") + assert self.cmd.matches_keyword(msg) is True + + def test_exclamation_prefix(self): + msg = mock_message(content="!channels") + assert self.cmd.matches_keyword(msg) is True + + def test_unrelated_command_no_match(self): + msg = mock_message(content="stats channels") + # "stats channels" starts with "stats", so "channels" part shouldn't match + result = self.cmd.matches_keyword(msg) + assert result is False + + def test_no_match_for_ping(self): + msg = mock_message(content="ping") + assert self.cmd.matches_keyword(msg) is False + + +# --------------------------------------------------------------------------- +# execute — basic flows +# --------------------------------------------------------------------------- + +class TestExecuteChannels: + def test_execute_no_channels_configured(self): + import asyncio + from unittest.mock import AsyncMock + bot = _make_bot_with_channels() + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = ChannelsCommand(bot) + msg = mock_message(content="channels", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_with_channels(self): + import asyncio + from unittest.mock import AsyncMock + bot = _make_bot_with_channels({"mesh": "Mesh network", "bot": "Bot channel"}) + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = ChannelsCommand(bot) + msg = mock_message(content="channels", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_list_subcommand(self): + import asyncio + from unittest.mock import AsyncMock + bot = _make_bot_with_channels({ + "mesh": "Mesh network", + "seattle.nw": "Northwest", + }) + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = ChannelsCommand(bot) + msg = mock_message(content="channels list", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_with_category_filter(self): + import asyncio + from unittest.mock import AsyncMock + bot = _make_bot_with_channels({ + "mesh": "General", + "seattle.nw": "Northwest", + "seattle.se": "Southeast", + }) + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = ChannelsCommand(bot) + msg = mock_message(content="channels seattle", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_with_exclamation(self): + import asyncio + from unittest.mock import AsyncMock + bot = _make_bot_with_channels({"mesh": "Mesh"}) + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = ChannelsCommand(bot) + msg = mock_message(content="!channels", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_specific_channel_request(self): + import asyncio + from unittest.mock import AsyncMock + bot = _make_bot_with_channels({"mesh": "Mesh network"}) + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = ChannelsCommand(bot) + msg = mock_message(content="channels #mesh", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_unknown_category_no_channels(self): + import asyncio + from unittest.mock import AsyncMock + bot = _make_bot_with_channels({"mesh": "Mesh"}) + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = ChannelsCommand(bot) + msg = mock_message(content="channels tokyo", channel="general") + result = asyncio.run(cmd.execute(msg)) + assert result is True + + +# --------------------------------------------------------------------------- +# get_help_text +# --------------------------------------------------------------------------- + +class TestChannelsGetHelpText: + def test_returns_string(self): + bot = _make_bot_with_channels() + cmd = ChannelsCommand(bot) + result = cmd.get_help_text() + assert isinstance(result, str) diff --git a/tests/test_command_manager.py b/tests/test_command_manager.py index c1bea14..906579a 100644 --- a/tests/test_command_manager.py +++ b/tests/test_command_manager.py @@ -527,3 +527,220 @@ class TestApplyAliases: make_manager(cm_bot, commands={"weather": wx_cmd}) assert "wx" in wx_cmd.keywords assert "w" in wx_cmd.keywords + + +class TestSendChannelMessageRetry: + """Tests for no_event_received retry logic in send_channel_message (BUG-025).""" + + def _make_no_event_result(self): + """Return a mock result that looks like EventType.ERROR / no_event_received.""" + from meshcore import EventType + r = MagicMock() + r.type = EventType.ERROR + r.payload = {'reason': 'no_event_received'} + return r + + def _make_success_result(self): + from meshcore import EventType + r = MagicMock() + r.type = EventType.MSG_SENT + r.payload = None + return r + + def _setup_bot(self, cm_bot): + cm_bot.connected = True + cm_bot.channel_manager = Mock() + cm_bot.channel_manager.get_channel_number = Mock(return_value=2) + cm_bot.meshcore = Mock() + cm_bot.meshcore.commands = Mock() + cm_bot.bot_tx_rate_limiter.wait_for_tx = AsyncMock(return_value=None) + cm_bot.channel_sent_listeners = [] + return cm_bot + + @pytest.mark.asyncio + async def test_success_on_first_attempt_no_retry(self, cm_bot): + """No retry when first attempt succeeds.""" + self._setup_bot(cm_bot) + cm_bot.meshcore.commands.send_chan_msg = AsyncMock( + return_value=self._make_success_result() + ) + manager = make_manager(cm_bot) + with patch("modules.command_manager.asyncio.sleep") as mock_sleep: + result = await manager.send_channel_message("general", "hi") + assert result is True + mock_sleep.assert_not_called() + assert cm_bot.meshcore.commands.send_chan_msg.call_count == 1 + + @pytest.mark.asyncio + async def test_retries_on_no_event_received_then_succeeds(self, cm_bot): + """Retries up to 2 times when no_event_received; succeeds on 3rd attempt.""" + self._setup_bot(cm_bot) + cm_bot.meshcore.commands.send_chan_msg = AsyncMock( + side_effect=[ + self._make_no_event_result(), + self._make_no_event_result(), + self._make_success_result(), + ] + ) + manager = make_manager(cm_bot) + with patch("modules.command_manager.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + result = await manager.send_channel_message("testing", "hello") + assert result is True + assert cm_bot.meshcore.commands.send_chan_msg.call_count == 3 + assert mock_sleep.call_count == 2 + mock_sleep.assert_called_with(2) + + @pytest.mark.asyncio + async def test_all_attempts_fail_returns_false(self, cm_bot): + """Returns False when all 3 attempts (initial + 2 retries) get no_event_received.""" + self._setup_bot(cm_bot) + cm_bot.meshcore.commands.send_chan_msg = AsyncMock( + return_value=self._make_no_event_result() + ) + manager = make_manager(cm_bot) + with patch("modules.command_manager.asyncio.sleep", new_callable=AsyncMock): + result = await manager.send_channel_message("testing", "hello") + assert result is False + assert cm_bot.meshcore.commands.send_chan_msg.call_count == 3 + + @pytest.mark.asyncio + async def test_is_no_event_received_helper(self, cm_bot): + """_is_no_event_received returns True only for ERROR/no_event_received.""" + from meshcore import EventType + manager = make_manager(cm_bot) + + no_event = self._make_no_event_result() + assert manager._is_no_event_received(no_event) is True + + success = self._make_success_result() + assert manager._is_no_event_received(success) is False + + assert manager._is_no_event_received(None) is False + + other_error = MagicMock() + other_error.type = EventType.ERROR + other_error.payload = {'reason': 'timeout'} + assert manager._is_no_event_received(other_error) is False + + @pytest.mark.asyncio + async def test_retry_only_fires_once_when_second_attempt_succeeds(self, cm_bot): + """Only one retry (sleep) when second attempt succeeds.""" + self._setup_bot(cm_bot) + cm_bot.meshcore.commands.send_chan_msg = AsyncMock( + side_effect=[ + self._make_no_event_result(), + self._make_success_result(), + ] + ) + manager = make_manager(cm_bot) + with patch("modules.command_manager.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + result = await manager.send_channel_message("general", "msg") + assert result is True + assert cm_bot.meshcore.commands.send_chan_msg.call_count == 2 + assert mock_sleep.call_count == 1 + + +class TestSplitTextIntoChunks: + """Tests for CommandManager.split_text_into_chunks.""" + + def test_short_text_single_chunk(self): + result = CommandManager.split_text_into_chunks("hello", 150) + assert result == ["hello"] + + def test_empty_string(self): + result = CommandManager.split_text_into_chunks("", 150) + assert result == [""] + + def test_exact_limit_single_chunk(self): + text = "a" * 150 + result = CommandManager.split_text_into_chunks(text, 150) + assert result == [text] + + def test_double_limit_two_chunks(self): + # 300 chars, limit 150 → 2 chunks + word = "word " # 5 chars + text = word * 60 # 300 chars, space-separated + result = CommandManager.split_text_into_chunks(text.strip(), 150) + assert len(result) == 2 + assert all(len(c) <= 150 for c in result) + assert " ".join(result) == text.strip() + + def test_five_times_limit_five_chunks(self): + # Construct text that is ~750 chars worth of space-separated words + word = "xy " # 3 chars + text = (word * 250).strip() # 749 chars + result = CommandManager.split_text_into_chunks(text, 150) + assert len(result) == 5 + assert all(len(c) <= 150 for c in result) + # Reassembling (space join) should equal original + assert " ".join(result) == text + + def test_no_content_dropped(self): + # Every character in original text must appear in exactly one chunk + import random + import string + random.seed(42) + words = ["".join(random.choices(string.ascii_lowercase, k=random.randint(3, 12))) for _ in range(60)] + text = " ".join(words) + chunks = CommandManager.split_text_into_chunks(text, 50) + assert all(len(c) <= 50 for c in chunks) + reassembled = " ".join(chunks) + assert reassembled == text + + def test_hard_split_no_spaces(self): + text = "a" * 300 + result = CommandManager.split_text_into_chunks(text, 100) + assert len(result) == 3 + assert all(len(c) == 100 for c in result) + + def test_max_len_one(self): + result = CommandManager.split_text_into_chunks("abc", 1) + assert len(result) == 3 + assert all(len(c) == 1 for c in result) + + +class TestGetMaxMessageLength: + """Tests for CommandManager.get_max_message_length.""" + + def _make_manager(self, bot_name: str = "Bot", username: str | None = None) -> CommandManager: + bot = Mock() + bot.logger = Mock() + bot.bot_root = Path("/tmp") + bot._local_root = None + bot.config = ConfigParser() + bot.config.add_section("Bot") + bot.config.set("Bot", "bot_name", bot_name) + bot.config.add_section("Channels") + bot.config.set("Channels", "monitor_channels", "general") + bot.config.set("Channels", "respond_to_dms", "true") + bot.config.add_section("Keywords") + if username is not None: + self_info = {"name": username} + meshcore = Mock() + meshcore.self_info = self_info + bot.meshcore = meshcore + else: + bot.meshcore = None + bot.translator = Mock() + bot.translator.translate = Mock(return_value="") + return make_manager(bot) + + def test_dm_returns_150(self): + mgr = self._make_manager() + msg = Mock() + msg.is_dm = True + assert mgr.get_max_message_length(msg) == 150 + + def test_channel_uses_bot_name(self): + mgr = self._make_manager(bot_name="LongBotName") + msg = Mock() + msg.is_dm = False + # 150 - len("LongBotName") - 2 = 150 - 11 - 2 = 137 + assert mgr.get_max_message_length(msg) == 137 + + def test_channel_uses_meshcore_username(self): + mgr = self._make_manager(bot_name="fallback", username="Radio") + msg = Mock() + msg.is_dm = False + # 150 - len("Radio") - 2 = 143 + assert mgr.get_max_message_length(msg) == 143 diff --git a/tests/test_dadjoke_command.py b/tests/test_dadjoke_command.py new file mode 100644 index 0000000..1fc7484 --- /dev/null +++ b/tests/test_dadjoke_command.py @@ -0,0 +1,227 @@ +"""Tests for modules.commands.dadjoke_command — pure logic functions.""" + +import configparser +from unittest.mock import MagicMock, Mock + +import pytest + +from modules.commands.dadjoke_command import DadJokeCommand +from tests.conftest import mock_message + + +def _make_bot(): + bot = MagicMock() + bot.logger = Mock() + config = configparser.ConfigParser() + config.add_section("Bot") + config.set("Bot", "bot_name", "TestBot") + config.add_section("Channels") + config.set("Channels", "monitor_channels", "general") + config.set("Channels", "respond_to_dms", "true") + config.add_section("Keywords") + config.add_section("DadJoke_Command") + config.set("DadJoke_Command", "enabled", "true") + config.set("DadJoke_Command", "long_jokes", "false") + bot.config = config + bot.translator = MagicMock() + bot.translator.translate = Mock(side_effect=lambda key, **kw: key) + bot.command_manager = MagicMock() + bot.command_manager.monitor_channels = ["general"] + return bot + + +class TestFormatDadJoke: + """Tests for format_dad_joke.""" + + def setup_method(self): + self.cmd = DadJokeCommand(_make_bot()) + + def test_formats_joke_with_emoji(self): + data = {"joke": "Why did the chicken cross the road?"} + result = self.cmd.format_dad_joke(data) + assert result.startswith("🥸") + assert "chicken" in result + + def test_empty_joke_returns_fallback(self): + data = {"joke": ""} + result = self.cmd.format_dad_joke(data) + assert "🥸" in result + + def test_missing_joke_key_returns_fallback(self): + data = {} + result = self.cmd.format_dad_joke(data) + assert "🥸" in result + + +class TestSplitDadJoke: + """Tests for split_dad_joke.""" + + def setup_method(self): + self.cmd = DadJokeCommand(_make_bot()) + + def test_splits_at_period(self): + joke = "🥸 Why did the chicken cross? It was there. The other side was better." + result = self.cmd.split_dad_joke(joke) + assert len(result) == 2 + assert result[0] and result[1] + + def test_splits_at_question_mark(self): + joke = "🥸 Why did the chicken cross the road? To get to the other side!" + result = self.cmd.split_dad_joke(joke) + assert len(result) == 2 + + def test_each_part_has_emoji(self): + joke = "🥸 Part one sentence here. Part two sentence continues." + result = self.cmd.split_dad_joke(joke) + for part in result: + assert "🥸" in part + + def test_no_split_point_splits_at_midpoint(self): + # Joke with no punctuation split points + joke = "🥸 abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz" + result = self.cmd.split_dad_joke(joke) + assert len(result) == 2 + + def test_short_joke_without_emoji(self): + joke = "Why did the chicken cross the road? To get to the other side!" + result = self.cmd.split_dad_joke(joke) + assert len(result) == 2 + + +class TestDadJokeLength: + """Tests for length-based behavior.""" + + def setup_method(self): + self.cmd = DadJokeCommand(_make_bot()) + + def test_short_joke_fits_in_130(self): + data = {"joke": "Why? Because!"} + text = self.cmd.format_dad_joke(data) + assert len(text) <= 130 + + def test_long_joke_exceeds_130(self): + long_joke = "a" * 200 + data = {"joke": long_joke} + text = self.cmd.format_dad_joke(data) + assert len(text) > 130 + + +class TestDadJokeMatchesKeyword: + """Tests for matches_keyword.""" + + def setup_method(self): + self.cmd = DadJokeCommand(_make_bot()) + + def test_dadjoke_matches(self): + msg = mock_message(content="dadjoke") + assert self.cmd.matches_keyword(msg) is True + + def test_dad_joke_matches(self): + msg = mock_message(content="dad joke") + assert self.cmd.matches_keyword(msg) is True + + def test_other_does_not_match(self): + msg = mock_message(content="joke") + assert self.cmd.matches_keyword(msg) is False + + def test_exclamation_prefix_handled(self): + msg = mock_message(content="!dadjoke") + assert self.cmd.matches_keyword(msg) is True + + +class TestDadJokeCanExecute: + def test_enabled_can_execute(self): + cmd = DadJokeCommand(_make_bot()) + msg = mock_message(content="dadjoke", channel="general") + assert cmd.can_execute(msg) is True + + def test_disabled_cannot_execute(self): + bot = _make_bot() + bot.config.set("DadJoke_Command", "enabled", "false") + cmd = DadJokeCommand(bot) + cmd.dadjoke_enabled = False + msg = mock_message(content="dadjoke", channel="general") + assert cmd.can_execute(msg) is False + + +class TestDadJokeGetHelpText: + def test_returns_usage_string(self): + cmd = DadJokeCommand(_make_bot()) + result = cmd.get_help_text() + assert "dadjoke" in result.lower() or "Usage" in result + + +class TestDadJokeExecute: + def test_execute_returns_true_with_joke(self): + import asyncio + from unittest.mock import AsyncMock, patch + + bot = _make_bot() + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = DadJokeCommand(bot) + msg = mock_message(content="dadjoke", channel="general") + + with patch.object(cmd, "get_dad_joke_with_length_handling", new_callable=AsyncMock, + return_value={"joke": "Why did the chicken cross? Because!"}): + result = asyncio.run(cmd.execute(msg)) + assert result is True + + def test_execute_returns_true_when_no_joke(self): + import asyncio + from unittest.mock import AsyncMock, patch + + bot = _make_bot() + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = DadJokeCommand(bot) + msg = mock_message(content="dadjoke", channel="general") + + with patch.object(cmd, "get_dad_joke_with_length_handling", new_callable=AsyncMock, + return_value=None): + result = asyncio.run(cmd.execute(msg)) + assert result is True + bot.command_manager.send_response.assert_called_once() + + def test_execute_handles_exception(self): + import asyncio + from unittest.mock import AsyncMock, patch + + bot = _make_bot() + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = DadJokeCommand(bot) + msg = mock_message(content="dadjoke", channel="general") + + with patch.object(cmd, "get_dad_joke_with_length_handling", new_callable=AsyncMock, + side_effect=Exception("API error")): + result = asyncio.run(cmd.execute(msg)) + assert result is True + + +class TestSendDadJokeWithLengthHandling: + def test_short_joke_sends_single_message(self): + import asyncio + from unittest.mock import AsyncMock + + bot = _make_bot() + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = DadJokeCommand(bot) + msg = mock_message(content="dadjoke") + + joke_data = {"joke": "Short joke!"} + asyncio.run(cmd.send_dad_joke_with_length_handling(msg, joke_data)) + bot.command_manager.send_response.assert_called_once() + + def test_long_joke_split_sends_two_messages(self): + import asyncio + from unittest.mock import AsyncMock + + bot = _make_bot() + bot.command_manager.send_response = AsyncMock(return_value=True) + cmd = DadJokeCommand(bot) + msg = mock_message(content="dadjoke") + + # Create a joke that's long enough to split (>130 chars) + long_joke = "Why did the chicken cross the road? " + "x" * 100 + ". Then it went back home." + joke_data = {"joke": long_joke} + asyncio.run(cmd.send_dad_joke_with_length_handling(msg, joke_data)) + # Should have been called (either once or twice depending on split) + assert bot.command_manager.send_response.call_count >= 1 diff --git a/tests/test_feed_manager.py b/tests/test_feed_manager.py index 1701374..c194b26 100644 --- a/tests/test_feed_manager.py +++ b/tests/test_feed_manager.py @@ -1,9 +1,11 @@ """Tests for FeedManager queue logic, deduplication, and DB operations.""" +import asyncio import sqlite3 import time from configparser import ConfigParser -from unittest.mock import Mock +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, Mock import pytest @@ -314,3 +316,506 @@ class TestRecordFeedError: ).fetchone() assert row["error_type"] == "network" assert "Connection refused" in row["error_message"] + + +# --------------------------------------------------------------------------- +# _format_timestamp (pure logic) +# --------------------------------------------------------------------------- + + +def _make_fm_no_db(): + """FeedManager with no DB — for pure logic tests.""" + bot = MagicMock() + bot.logger = Mock() + config = ConfigParser() + config.add_section("Bot") + bot.config = config + bot.db_manager = MagicMock() + bot.db_manager.db_path = ":memory:" + return FeedManager(bot) + + +class TestFormatTimestamp: + def setup_method(self): + self.fm = _make_fm_no_db() + + def test_none_returns_empty(self): + assert self.fm._format_timestamp(None) == "" + + def test_just_now(self): + dt = datetime.now(timezone.utc) + result = self.fm._format_timestamp(dt) + assert result == "now" + + def test_30_minutes_ago(self): + dt = datetime.now(timezone.utc) - timedelta(minutes=30) + result = self.fm._format_timestamp(dt) + assert "m ago" in result + + def test_3_hours_ago(self): + dt = datetime.now(timezone.utc) - timedelta(hours=3, minutes=15) + result = self.fm._format_timestamp(dt) + assert "h" in result and "m ago" in result + + def test_5_days_ago(self): + dt = datetime.now(timezone.utc) - timedelta(days=5) + result = self.fm._format_timestamp(dt) + assert "5d ago" == result + + def test_naive_datetime(self): + dt = datetime.now() - timedelta(hours=2) + result = self.fm._format_timestamp(dt) + assert isinstance(result, str) + + +# --------------------------------------------------------------------------- +# _apply_shortening (pure logic) +# --------------------------------------------------------------------------- + + +class TestApplyShortening: + def setup_method(self): + self.fm = _make_fm_no_db() + + def test_empty_text_returns_empty(self): + assert self.fm._apply_shortening("", "truncate:50") == "" + + def test_truncate_short_text_unchanged(self): + assert self.fm._apply_shortening("hi", "truncate:50") == "hi" + + def test_truncate_long_text(self): + result = self.fm._apply_shortening("a" * 100, "truncate:10") + assert result.endswith("...") + assert len(result) <= 13 + + def test_truncate_invalid_number(self): + result = self.fm._apply_shortening("hello", "truncate:abc") + assert result == "hello" + + def test_word_wrap_short_unchanged(self): + assert self.fm._apply_shortening("hello world", "word_wrap:50") == "hello world" + + def test_word_wrap_long_truncates(self): + text = "hello world this is a long sentence here" + result = self.fm._apply_shortening(text, "word_wrap:20") + assert result.endswith("...") + + def test_first_words_few_unchanged(self): + assert self.fm._apply_shortening("one two", "first_words:5") == "one two" + + def test_first_words_truncates(self): + result = self.fm._apply_shortening("one two three four five", "first_words:3") + assert result == "one two three..." + + def test_regex_extracts_group(self): + result = self.fm._apply_shortening("Price: $42", "regex:\\$(\\d+)") + assert result == "42" + + def test_regex_whole_match_no_group(self): + result = self.fm._apply_shortening("hello world", "regex:hello") + assert result == "hello" + + def test_regex_no_match_returns_empty(self): + result = self.fm._apply_shortening("hello", "regex:xyz") + assert result == "" + + def test_regex_with_group_0(self): + result = self.fm._apply_shortening("abc 123", "regex:abc \\d+:0") + assert result == "abc 123" + + def test_if_regex_matches(self): + result = self.fm._apply_shortening("red alert", "if_regex:red:yes:no") + assert result == "yes" + + def test_if_regex_no_match(self): + result = self.fm._apply_shortening("blue alert", "if_regex:red:yes:no") + assert result == "no" + + def test_switch_matches(self): + result = self.fm._apply_shortening("high", "switch:highest:🔴:high:🟠:medium:🟡:⚪") + assert result == "🟠" + + def test_switch_default(self): + result = self.fm._apply_shortening("unknown", "switch:highest:🔴:high:🟠:⚪") + assert result == "⚪" + + def test_unknown_function_returns_text(self): + result = self.fm._apply_shortening("hello", "unknown_func") + assert result == "hello" + + def test_regex_cond_matches_then_value(self): + result = self.fm._apply_shortening( + "No restrictions here", + "regex_cond:(No restrictions):No restrictions:👍:1" + ) + assert result == "👍" + + def test_regex_cond_no_extract_match(self): + result = self.fm._apply_shortening( + "Some data", + "regex_cond:(Missing pattern):check:yes:1" + ) + assert result == "" + + +# --------------------------------------------------------------------------- +# _get_nested_value (pure logic) +# --------------------------------------------------------------------------- + + +class TestGetNestedValue: + def setup_method(self): + self.fm = _make_fm_no_db() + + def test_simple_key(self): + assert self.fm._get_nested_value({"a": 1}, "a") == 1 + + def test_nested_key(self): + data = {"a": {"b": {"c": "deep"}}} + assert self.fm._get_nested_value(data, "a.b.c") == "deep" + + def test_missing_key_default(self): + assert self.fm._get_nested_value({"a": 1}, "b", "fb") == "fb" + + def test_list_index(self): + assert self.fm._get_nested_value({"items": ["x", "y", "z"]}, "items.1") == "y" + + def test_list_out_of_bounds_default(self): + assert self.fm._get_nested_value({"items": ["x"]}, "items.5", "def") == "def" + + def test_none_data_returns_default(self): + assert self.fm._get_nested_value(None, "a", "def") == "def" + + def test_empty_path_returns_default(self): + assert self.fm._get_nested_value({"a": 1}, "", "def") == "def" + + def test_none_in_path_returns_default(self): + assert self.fm._get_nested_value({"a": None}, "a.b", "def") == "def" + + def test_list_non_integer_index_default(self): + assert self.fm._get_nested_value({"items": [1, 2]}, "items.notnum", "def") == "def" + + def test_scalar_then_nested_default(self): + assert self.fm._get_nested_value({"a": 42}, "a.b", "def") == "def" + + +# --------------------------------------------------------------------------- +# _parse_microsoft_date (pure logic) +# --------------------------------------------------------------------------- + + +class TestParseMicrosoftDate: + def setup_method(self): + self.fm = _make_fm_no_db() + + def test_valid_utc_date(self): + result = self.fm._parse_microsoft_date("/Date(1609459200000)/") + assert isinstance(result, datetime) + + def test_positive_offset(self): + result = self.fm._parse_microsoft_date("/Date(1609459200000+0800)/") + assert isinstance(result, datetime) + + def test_negative_offset(self): + result = self.fm._parse_microsoft_date("/Date(1609459200000-0500)/") + assert isinstance(result, datetime) + + def test_none_returns_none(self): + assert self.fm._parse_microsoft_date(None) is None + + def test_empty_returns_none(self): + assert self.fm._parse_microsoft_date("") is None + + def test_non_ms_format_returns_none(self): + assert self.fm._parse_microsoft_date("2021-01-01") is None + + def test_non_string_returns_none(self): + assert self.fm._parse_microsoft_date(12345) is None + + +# --------------------------------------------------------------------------- +# format_message (pure logic) +# --------------------------------------------------------------------------- + + +class TestFormatMessage: + def setup_method(self): + self.fm = _make_fm_no_db() + + def _item(self, **kw): + base = { + "title": "Test Title", + "description": "Test body text", + "link": "http://example.com/1", + "published": datetime.now(timezone.utc) - timedelta(minutes=5), + } + base.update(kw) + return base + + def _feed(self, fmt="{emoji} {title}", name="test"): + return {"feed_name": name, "output_format": fmt} + + def test_basic_returns_string(self): + result = self.fm.format_message(self._item(), self._feed()) + assert isinstance(result, str) + assert "Test Title" in result + + def test_default_emoji(self): + result = self.fm.format_message(self._item(), self._feed()) + assert "📢" in result + + def test_emergency_emoji(self): + result = self.fm.format_message(self._item(), self._feed(name="emergency")) + assert "🚨" in result + + def test_warning_emoji(self): + result = self.fm.format_message(self._item(), self._feed(name="weather warning")) + assert "⚠️" in result + + def test_news_emoji(self): + result = self.fm.format_message(self._item(), self._feed(name="news feed")) + assert "ℹ️" in result + + def test_date_placeholder(self): + result = self.fm.format_message(self._item(), self._feed(fmt="{date}")) + assert "ago" in result or result == "now" + + def test_link_placeholder(self): + result = self.fm.format_message(self._item(), self._feed(fmt="{link}")) + assert "example.com" in result + + def test_body_html_stripped(self): + item = self._item(description="
Hello world
") + result = self.fm.format_message(item, self._feed(fmt="{body}")) + assert "" not in result
+ assert "Hello" in result
+
+ def test_body_br_to_newline(self):
+ item = self._item(description="Line1
Line2")
+ result = self.fm.format_message(item, self._feed(fmt="{body}"))
+ assert "\n" in result
+
+ def test_raw_field(self):
+ item = self._item(raw={"Priority": "High"})
+ result = self.fm.format_message(item, self._feed(fmt="{raw.Priority}"))
+ assert "High" in result
+
+ def test_raw_field_truncate(self):
+ item = self._item(raw={"Detail": "a" * 200})
+ result = self.fm.format_message(item, self._feed(fmt="{raw.Detail|truncate:10}"))
+ assert len(result) <= 13
+
+ def test_long_message_truncated(self):
+ self.fm.max_message_length = 50
+ item = self._item(title="a" * 200)
+ result = self.fm.format_message(item, self._feed(fmt="{title}"))
+ assert len(result) <= 53
+
+ def test_multiline_long_truncated(self):
+ self.fm.max_message_length = 60
+ item = self._item(title="Title here", description="x" * 100)
+ result = self.fm.format_message(item, self._feed(fmt="{title}\n{body}"))
+ assert isinstance(result, str)
+
+ def test_no_output_format_uses_default(self):
+ feed = {"feed_name": "test", "output_format": None}
+ result = self.fm.format_message(self._item(), feed)
+ assert isinstance(result, str)
+
+ def test_raw_dict_serialized(self):
+ item = self._item(raw={"nested": {"key": "val"}})
+ result = self.fm.format_message(item, self._feed(fmt="{raw.nested}"))
+ assert isinstance(result, str)
+
+ def test_truncate_function_on_title(self):
+ item = self._item(title="a" * 100)
+ result = self.fm.format_message(item, self._feed(fmt="{title|truncate:20}"))
+ assert result.endswith("...")
+ assert len(result) <= 23
+
+
+# ---------------------------------------------------------------------------
+# _should_send_item (pure logic — no DB)
+# ---------------------------------------------------------------------------
+
+
+class TestShouldSendItem:
+ def setup_method(self):
+ self.fm = _make_fm_no_db()
+
+ def _feed(self, filter_cfg=None):
+ return {"id": 1, "filter_config": filter_cfg}
+
+ def _item(self, raw=None, **kw):
+ base = {"title": "Test", "raw": raw or {}}
+ base.update(kw)
+ return base
+
+ def test_no_filter_sends_all(self):
+ assert self.fm._should_send_item(self._feed(), self._item()) is True
+
+ def test_invalid_json_filter_sends_all(self):
+ assert self.fm._should_send_item(self._feed("not json"), self._item()) is True
+
+ def test_empty_conditions_sends_all(self):
+ import json
+ fc = json.dumps({"conditions": []})
+ assert self.fm._should_send_item(self._feed(fc), self._item()) is True
+
+ def test_equals_match(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "Priority", "operator": "equals", "value": "High"}]})
+ item = self._item(raw={"Priority": "High"})
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+ def test_equals_no_match(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "Priority", "operator": "equals", "value": "High"}]})
+ item = self._item(raw={"Priority": "Low"})
+ assert self.fm._should_send_item(self._feed(fc), item) is False
+
+ def test_not_equals(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "Status", "operator": "not_equals", "value": "Closed"}]})
+ item = self._item(raw={"Status": "Open"})
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+ def test_in_operator(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "Priority", "operator": "in", "values": ["high", "highest"]}]})
+ item = self._item(raw={"Priority": "high"})
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+ def test_not_in_operator(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "Category", "operator": "not_in", "values": ["maintenance"]}]})
+ item = self._item(raw={"Category": "Incident"})
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+ def test_matches_operator(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "Priority", "operator": "matches", "pattern": "^(high|highest)$"}]})
+ item = self._item(raw={"Priority": "high"})
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+ def test_not_matches_operator(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "Priority", "operator": "not_matches", "pattern": "^low$"}]})
+ item = self._item(raw={"Priority": "high"})
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+ def test_contains_operator(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "Title", "operator": "contains", "value": "accident"}]})
+ item = self._item(raw={"Title": "Traffic accident on I-5"})
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+ def test_not_contains_operator(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "Title", "operator": "not_contains", "value": "planned"}]})
+ item = self._item(raw={"Title": "Unexpected outage"})
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+ def test_or_logic(self):
+ import json
+ fc = json.dumps({
+ "conditions": [
+ {"field": "Priority", "operator": "equals", "value": "high"},
+ {"field": "Priority", "operator": "equals", "value": "medium"},
+ ],
+ "logic": "OR"
+ })
+ item = self._item(raw={"Priority": "medium"})
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+ def test_and_logic_fails_when_one_false(self):
+ import json
+ fc = json.dumps({
+ "conditions": [
+ {"field": "Priority", "operator": "equals", "value": "high"},
+ {"field": "Status", "operator": "equals", "value": "open"},
+ ],
+ "logic": "AND"
+ })
+ item = self._item(raw={"Priority": "high", "Status": "closed"})
+ assert self.fm._should_send_item(self._feed(fc), item) is False
+
+ def test_raw_prefix_field_access(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "raw.Priority", "operator": "equals", "value": "High"}]})
+ item = self._item(raw={"Priority": "High"})
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+ def test_top_level_field_fallback(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "title", "operator": "contains", "value": "test"}]})
+ item = {"title": "Test Article", "raw": {}}
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+ def test_unknown_operator_defaults_true(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "Priority", "operator": "unknown_op"}]})
+ item = self._item(raw={"Priority": "High"})
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+ def test_invalid_regex_in_matches_returns_false(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "P", "operator": "matches", "pattern": "[invalid"}]})
+ item = self._item(raw={"P": "val"})
+ assert self.fm._should_send_item(self._feed(fc), item) is False
+
+ def test_invalid_regex_in_not_matches_returns_true(self):
+ import json
+ fc = json.dumps({"conditions": [{"field": "P", "operator": "not_matches", "pattern": "[invalid"}]})
+ item = self._item(raw={"P": "val"})
+ assert self.fm._should_send_item(self._feed(fc), item) is True
+
+
+# ---------------------------------------------------------------------------
+# _sort_items (pure logic)
+# ---------------------------------------------------------------------------
+
+
+class TestSortItems:
+ def setup_method(self):
+ self.fm = _make_fm_no_db()
+
+ def test_empty_config_returns_unchanged(self):
+ items = [{"title": "b"}, {"title": "a"}]
+ assert self.fm._sort_items(items, {}) == items
+
+ def test_empty_items_returns_empty(self):
+ assert self.fm._sort_items([], {"field": "title"}) == []
+
+ def test_no_field_path_returns_unchanged(self):
+ items = [{"title": "b"}, {"title": "a"}]
+ assert self.fm._sort_items(items, {"order": "asc"}) == items
+
+ def test_sort_numeric_asc(self):
+ items = [{"raw": {"score": 3}}, {"raw": {"score": 1}}, {"raw": {"score": 2}}]
+ result = self.fm._sort_items(items, {"field": "score", "order": "asc"})
+ scores = [r["raw"]["score"] for r in result]
+ assert scores == sorted(scores)
+
+ def test_sort_numeric_desc(self):
+ items = [{"raw": {"score": 1}}, {"raw": {"score": 3}}, {"raw": {"score": 2}}]
+ result = self.fm._sort_items(items, {"field": "score", "order": "desc"})
+ scores = [r["raw"]["score"] for r in result]
+ assert scores == sorted(scores, reverse=True)
+
+ def test_sort_by_iso_date_string(self):
+ items = [
+ {"raw": {"date": "2021-01-03"}},
+ {"raw": {"date": "2021-01-01"}},
+ {"raw": {"date": "2021-01-02"}},
+ ]
+ result = self.fm._sort_items(items, {"field": "date", "order": "asc"})
+ assert isinstance(result, list)
+
+ def test_sort_by_microsoft_date(self):
+ items = [
+ {"raw": {"ts": "/Date(1609500000000)/"}},
+ {"raw": {"ts": "/Date(1609400000000)/"}},
+ ]
+ result = self.fm._sort_items(items, {"field": "ts", "order": "desc"})
+ assert isinstance(result, list)
diff --git a/tests/test_graph_trace_helper.py b/tests/test_graph_trace_helper.py
new file mode 100644
index 0000000..3f820c1
--- /dev/null
+++ b/tests/test_graph_trace_helper.py
@@ -0,0 +1,320 @@
+"""Tests for modules.graph_trace_helper — update_mesh_graph_from_trace_data."""
+
+import configparser
+from unittest.mock import MagicMock, Mock, call, patch
+
+import pytest
+
+from modules.graph_trace_helper import update_mesh_graph_from_trace_data
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _make_bot(bot_prefix="aa", has_mesh_graph=True, has_transmission_tracker=True):
+ """Create a minimal mock bot for graph_trace_helper tests."""
+ bot = MagicMock()
+ bot.logger = Mock()
+
+ config = configparser.ConfigParser()
+ config.add_section("Path_Command")
+ config.set("Path_Command", "graph_edge_expiration_days", "7")
+ bot.config = config
+
+ if has_mesh_graph:
+ bot.mesh_graph = MagicMock()
+ else:
+ bot.mesh_graph = None
+
+ if has_transmission_tracker:
+ bot.transmission_tracker = MagicMock()
+ bot.transmission_tracker.bot_prefix = bot_prefix
+ bot.transmission_tracker.match_packet_hash = Mock(return_value=None)
+ else:
+ bot.transmission_tracker = None
+
+ # DB manager returns empty results by default
+ bot.db_manager = MagicMock()
+ bot.db_manager.execute_query = Mock(return_value=[])
+
+ # meshcore device (optional)
+ bot.meshcore = MagicMock()
+ bot.meshcore.device = MagicMock()
+ bot.meshcore.device.public_key = "aa" * 32
+
+ return bot
+
+
+# ---------------------------------------------------------------------------
+# Early exit / guard cases
+# ---------------------------------------------------------------------------
+
+class TestEarlyExits:
+ def test_empty_path_hashes_returns_immediately(self):
+ bot = _make_bot()
+ update_mesh_graph_from_trace_data(bot, [], {})
+ bot.mesh_graph.add_edge.assert_not_called()
+
+ def test_none_path_hashes_treated_as_empty(self):
+ bot = _make_bot()
+ # empty list is falsy, so this is covered by the guard
+ update_mesh_graph_from_trace_data(bot, [], {})
+ bot.mesh_graph.add_edge.assert_not_called()
+
+ def test_no_mesh_graph_returns_immediately(self):
+ bot = _make_bot(has_mesh_graph=False)
+ update_mesh_graph_from_trace_data(bot, ["ab"], {})
+ # Should log and return without crash
+ bot.logger.debug.assert_called()
+
+ def test_no_transmission_tracker_returns_immediately(self):
+ bot = _make_bot(has_transmission_tracker=False)
+ update_mesh_graph_from_trace_data(bot, ["ab"], {})
+ bot.logger.debug.assert_called()
+
+ def test_missing_mesh_graph_attribute(self):
+ bot = _make_bot()
+ del bot.mesh_graph
+ update_mesh_graph_from_trace_data(bot, ["ab"], {})
+ # No crash expected
+
+ def test_missing_transmission_tracker_attribute(self):
+ bot = _make_bot()
+ del bot.transmission_tracker
+ update_mesh_graph_from_trace_data(bot, ["ab"], {})
+ # No crash expected
+
+ def test_empty_bot_prefix_returns_immediately(self):
+ bot = _make_bot()
+ bot.transmission_tracker.bot_prefix = None
+ update_mesh_graph_from_trace_data(bot, ["ab"], {})
+ bot.mesh_graph.add_edge.assert_not_called()
+
+ def test_empty_string_bot_prefix_returns_immediately(self):
+ bot = _make_bot()
+ bot.transmission_tracker.bot_prefix = ""
+ update_mesh_graph_from_trace_data(bot, ["ab"], {})
+ bot.mesh_graph.add_edge.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# is_our_trace resolution
+# ---------------------------------------------------------------------------
+
+class TestIsOurTraceResolution:
+ def test_is_our_trace_none_resolves_false_when_no_match(self):
+ """When packet_hash doesn't match, is_our_trace stays False."""
+ bot = _make_bot(bot_prefix="aa")
+ bot.transmission_tracker.match_packet_hash = Mock(return_value=None)
+ update_mesh_graph_from_trace_data(bot, ["bb"], {"packet_hash": "abc123"})
+ # Not our trace → should add one edge (last_node → bot)
+ bot.mesh_graph.add_edge.assert_called_once()
+
+ def test_is_our_trace_none_resolves_true_when_match(self):
+ """When packet_hash matches a transmission record, is_our_trace becomes True."""
+ bot = _make_bot(bot_prefix="aa")
+ bot.transmission_tracker.match_packet_hash = Mock(return_value={"matched": True})
+ # Single hop — should trigger immediate-neighbor path (bidirectional)
+ update_mesh_graph_from_trace_data(bot, ["bb"], {"packet_hash": "abc123"})
+ # Bidirectional: 2 add_edge calls
+ assert bot.mesh_graph.add_edge.call_count == 2
+
+ def test_is_our_trace_explicit_true(self):
+ """Explicit is_our_trace=True skips transmission tracker lookup."""
+ bot = _make_bot(bot_prefix="aa")
+ bot.transmission_tracker.match_packet_hash = Mock(return_value=None)
+ update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
+ # Single hop + explicit True → immediate neighbor → bidirectional
+ assert bot.mesh_graph.add_edge.call_count == 2
+
+ def test_is_our_trace_explicit_false(self):
+ """Explicit is_our_trace=False skips transmission tracker lookup."""
+ bot = _make_bot(bot_prefix="aa")
+ bot.transmission_tracker.match_packet_hash = Mock(return_value={"matched": True})
+ update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=False)
+ # Not our trace even though match_packet_hash would return a record
+ bot.mesh_graph.add_edge.assert_called_once()
+
+ def test_is_our_trace_true_no_packet_hash(self):
+ """is_our_trace None with no packet_hash defaults to False."""
+ bot = _make_bot(bot_prefix="aa")
+ update_mesh_graph_from_trace_data(bot, ["bb"], {})
+ # No packet_hash → is_our_trace stays False → one edge
+ bot.mesh_graph.add_edge.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# Immediate neighbor (single hop, is_our_trace=True)
+# ---------------------------------------------------------------------------
+
+class TestImmediateNeighbor:
+ def test_single_hop_creates_bidirectional_edges(self):
+ bot = _make_bot(bot_prefix="aa")
+ update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
+ assert bot.mesh_graph.add_edge.call_count == 2
+
+ def test_single_hop_edge_directions(self):
+ bot = _make_bot(bot_prefix="aa")
+ update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
+ calls = bot.mesh_graph.add_edge.call_args_list
+ from_prefixes = {c.kwargs.get("from_prefix") or c[1].get("from_prefix") for c in calls}
+ to_prefixes = {c.kwargs.get("to_prefix") or c[1].get("to_prefix") for c in calls}
+ # Both directions: aa↔bb
+ assert "aa" in from_prefixes or "aa" in to_prefixes
+ assert "bb" in from_prefixes or "bb" in to_prefixes
+
+ def test_single_hop_lowercase_normalization(self):
+ """Path hashes are lowercased before use."""
+ bot = _make_bot(bot_prefix="AA")
+ update_mesh_graph_from_trace_data(bot, ["BB"], {}, is_our_trace=True)
+ assert bot.mesh_graph.add_edge.call_count == 2
+ calls = bot.mesh_graph.add_edge.call_args_list
+ # Check lowercase normalization
+ all_prefixes = set()
+ for c in calls:
+ all_prefixes.add(c.kwargs.get("from_prefix") or (c[0][0] if c[0] else None))
+ all_prefixes.add(c.kwargs.get("to_prefix") or (c[0][1] if len(c[0]) > 1 else None))
+ # Should contain lowercase versions
+ assert "aa" in all_prefixes or "bb" in all_prefixes
+
+ def test_single_hop_with_unique_db_key(self):
+ """When DB returns exactly one public_key for the neighbor, it's used."""
+ bot = _make_bot(bot_prefix="aa")
+ # First query returns count=1, second returns the key
+ bot.db_manager.execute_query = Mock(side_effect=[
+ [{"count": 1}],
+ [{"public_key": "bb" * 32}],
+ [], # bot location query
+ ])
+ update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
+ assert bot.mesh_graph.add_edge.call_count == 2
+
+ def test_single_hop_with_ambiguous_db_results(self):
+ """When DB returns count != 1, no key is resolved but edges still added."""
+ bot = _make_bot(bot_prefix="aa")
+ bot.db_manager.execute_query = Mock(return_value=[{"count": 2}])
+ update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
+ assert bot.mesh_graph.add_edge.call_count == 2
+
+ def test_single_hop_db_exception_handled(self):
+ """DB exceptions during key lookup are swallowed; edges still added."""
+ bot = _make_bot(bot_prefix="aa")
+ bot.db_manager.execute_query = Mock(side_effect=Exception("DB error"))
+ update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
+ assert bot.mesh_graph.add_edge.call_count == 2
+
+ def test_single_hop_no_meshcore_device(self):
+ """No meshcore device → bot_key is None, edges still added."""
+ bot = _make_bot(bot_prefix="aa")
+ bot.meshcore = None
+ update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
+ assert bot.mesh_graph.add_edge.call_count == 2
+
+ def test_single_hop_device_pubkey_bytes(self):
+ """Device public key as bytes is hex-encoded."""
+ bot = _make_bot(bot_prefix="aa")
+ bot.meshcore.device.public_key = b"\xaa\xbb"
+ update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
+ assert bot.mesh_graph.add_edge.call_count == 2
+
+
+# ---------------------------------------------------------------------------
+# Regular trace (bot is destination, not immediate neighbor)
+# ---------------------------------------------------------------------------
+
+class TestRegularTrace:
+ def test_two_hop_creates_two_edges(self):
+ """[a, b] path (bot receives from b via a): two edges expected."""
+ bot = _make_bot(bot_prefix="cc")
+ update_mesh_graph_from_trace_data(bot, ["aa", "bb"], {})
+ # 1 edge: last_node(bb) → bot(cc)
+ # 1 edge: aa → bb
+ assert bot.mesh_graph.add_edge.call_count == 2
+
+ def test_single_node_path_creates_one_edge(self):
+ """Single-node path: last_node → bot."""
+ bot = _make_bot(bot_prefix="cc")
+ update_mesh_graph_from_trace_data(bot, ["aa"], {})
+ assert bot.mesh_graph.add_edge.call_count == 1
+
+ def test_three_hop_creates_three_edges(self):
+ """[a, b, c] path creates 3 edges."""
+ bot = _make_bot(bot_prefix="dd")
+ update_mesh_graph_from_trace_data(bot, ["aa", "bb", "cc"], {})
+ assert bot.mesh_graph.add_edge.call_count == 3
+
+ def test_path_hashes_lowercased(self):
+ """Uppercase path hashes are normalized to lowercase."""
+ bot = _make_bot(bot_prefix="cc")
+ update_mesh_graph_from_trace_data(bot, ["AA", "BB"], {})
+ assert bot.mesh_graph.add_edge.call_count == 2
+ calls = bot.mesh_graph.add_edge.call_args_list
+ # All prefixes should be lowercase
+ for c in calls:
+ kw = c.kwargs
+ if "from_prefix" in kw:
+ assert kw["from_prefix"] == kw["from_prefix"].lower()
+ if "to_prefix" in kw:
+ assert kw["to_prefix"] == kw["to_prefix"].lower()
+
+ def test_last_edge_points_to_bot(self):
+ """The last_node→bot edge has to_prefix equal to bot_prefix."""
+ bot = _make_bot(bot_prefix="cc")
+ update_mesh_graph_from_trace_data(bot, ["aa"], {})
+ call_kwargs = bot.mesh_graph.add_edge.call_args.kwargs
+ assert call_kwargs.get("to_prefix") == "cc"
+ assert call_kwargs.get("from_prefix") == "aa"
+
+ def test_db_exception_in_regular_path_handled(self):
+ """DB exceptions don't prevent edges from being added."""
+ bot = _make_bot(bot_prefix="cc")
+ bot.db_manager.execute_query = Mock(side_effect=Exception("DB error"))
+ update_mesh_graph_from_trace_data(bot, ["aa", "bb"], {})
+ assert bot.mesh_graph.add_edge.call_count == 2
+
+ def test_unique_key_resolved_for_last_node(self):
+ """When exactly one key matches the last_node prefix, it's used."""
+ bot = _make_bot(bot_prefix="cc")
+ bot.db_manager.execute_query = Mock(side_effect=[
+ [{"count": 1}],
+ [{"public_key": "aa" * 32}],
+ ])
+ update_mesh_graph_from_trace_data(bot, ["aa"], {})
+ assert bot.mesh_graph.add_edge.call_count == 1
+
+ def test_mesh_graph_add_edge_exception_propagates(self):
+ """Exception from add_edge is not caught (it's a programming error)."""
+ bot = _make_bot(bot_prefix="cc")
+ bot.mesh_graph.add_edge = Mock(side_effect=RuntimeError("mesh error"))
+ with pytest.raises(RuntimeError):
+ update_mesh_graph_from_trace_data(bot, ["aa"], {})
+
+
+# ---------------------------------------------------------------------------
+# Multi-hop intermediate edges
+# ---------------------------------------------------------------------------
+
+class TestMultiHopEdges:
+ def test_hop_positions_decrease_towards_bot(self):
+ """Hop positions are assigned based on distance from bot."""
+ bot = _make_bot(bot_prefix="dd")
+ update_mesh_graph_from_trace_data(bot, ["aa", "bb", "cc"], {})
+ calls = bot.mesh_graph.add_edge.call_args_list
+ # Collect hop_position values from kwargs
+ hop_positions = [c.kwargs.get("hop_position") for c in calls]
+ assert all(h is not None for h in hop_positions)
+
+ def test_single_hop_position_is_1(self):
+ """Single-hop path has hop_position=1."""
+ bot = _make_bot(bot_prefix="bb")
+ update_mesh_graph_from_trace_data(bot, ["aa"], {})
+ call_kwargs = bot.mesh_graph.add_edge.call_args.kwargs
+ assert call_kwargs.get("hop_position") == 1
+
+ def test_no_meshcore_in_regular_path(self):
+ """No meshcore still creates edges."""
+ bot = _make_bot(bot_prefix="cc")
+ bot.meshcore = None
+ update_mesh_graph_from_trace_data(bot, ["aa", "bb"], {})
+ assert bot.mesh_graph.add_edge.call_count == 2
diff --git a/tests/test_hacker_command.py b/tests/test_hacker_command.py
new file mode 100644
index 0000000..7ed8c90
--- /dev/null
+++ b/tests/test_hacker_command.py
@@ -0,0 +1,360 @@
+"""Tests for modules.commands.hacker_command — get_hacker_error and matches_keyword."""
+
+import asyncio
+import configparser
+from unittest.mock import AsyncMock, MagicMock, Mock
+
+import pytest
+
+from modules.commands.hacker_command import HackerCommand
+from tests.conftest import mock_message
+
+
+# ---------------------------------------------------------------------------
+# Bot factory
+# ---------------------------------------------------------------------------
+
+def _make_bot(enabled=True):
+ bot = MagicMock()
+ bot.logger = Mock()
+
+ config = configparser.ConfigParser()
+ config.add_section("Bot")
+ config.set("Bot", "bot_name", "TestBot")
+ config.add_section("Channels")
+ config.set("Channels", "monitor_channels", "general")
+ config.set("Channels", "respond_to_dms", "true")
+ config.add_section("Keywords")
+ config.add_section("Hacker_Command")
+ config.set("Hacker_Command", "enabled", "true" if enabled else "false")
+
+ bot.config = config
+ bot.translator = MagicMock()
+ bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
+ bot.translator.get_value = Mock(return_value=None) # No translations → use fallback
+ bot.command_manager = MagicMock()
+ bot.command_manager.monitor_channels = ["general"]
+ bot.command_manager.send_response = AsyncMock(return_value=True)
+
+ return bot
+
+
+def _run(coro):
+ return asyncio.run(coro)
+
+
+# ---------------------------------------------------------------------------
+# get_hacker_error — one test per command category
+# ---------------------------------------------------------------------------
+
+class TestGetHackerError:
+ def setup_method(self):
+ self.cmd = HackerCommand(_make_bot())
+
+ def test_sudo_error(self):
+ result = self.cmd.get_hacker_error("sudo rm -rf /")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_ps_aux_error(self):
+ result = self.cmd.get_hacker_error("ps aux")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_grep_error(self):
+ result = self.cmd.get_hacker_error("grep -r password /etc")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_ls_l_error(self):
+ result = self.cmd.get_hacker_error("ls -l /home")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_ls_la_error(self):
+ result = self.cmd.get_hacker_error("ls -la")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_echo_path_error(self):
+ result = self.cmd.get_hacker_error("echo $PATH")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_rm_rf_error(self):
+ result = self.cmd.get_hacker_error("rm -rf /")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_rm_r_error(self):
+ result = self.cmd.get_hacker_error("rm -r mydir")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_rm_error(self):
+ result = self.cmd.get_hacker_error("rm file.txt")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_cat_error(self):
+ result = self.cmd.get_hacker_error("cat /etc/passwd")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_whoami_error(self):
+ result = self.cmd.get_hacker_error("whoami")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_top_error(self):
+ result = self.cmd.get_hacker_error("top")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_htop_error(self):
+ result = self.cmd.get_hacker_error("htop")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_netstat_error(self):
+ result = self.cmd.get_hacker_error("netstat -an")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_ss_error(self):
+ result = self.cmd.get_hacker_error("ss -tlnp")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_kill_error(self):
+ result = self.cmd.get_hacker_error("kill 1234")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_killall_error(self):
+ result = self.cmd.get_hacker_error("killall nginx")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_chmod_error(self):
+ result = self.cmd.get_hacker_error("chmod 777 /etc")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_find_error(self):
+ result = self.cmd.get_hacker_error("find / -name '*.conf'")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_history_error(self):
+ result = self.cmd.get_hacker_error("history")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_passwd_error(self):
+ result = self.cmd.get_hacker_error("passwd root")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_su_error(self):
+ result = self.cmd.get_hacker_error("su root")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_ssh_error(self):
+ result = self.cmd.get_hacker_error("ssh user@host")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_wget_error(self):
+ result = self.cmd.get_hacker_error("wget http://example.com/file")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_curl_error(self):
+ result = self.cmd.get_hacker_error("curl http://example.com")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_df_h_error(self):
+ result = self.cmd.get_hacker_error("df -h")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_df_error(self):
+ result = self.cmd.get_hacker_error("df")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_free_error(self):
+ result = self.cmd.get_hacker_error("free -h")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_ifconfig_error(self):
+ result = self.cmd.get_hacker_error("ifconfig eth0")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_ip_addr_error(self):
+ result = self.cmd.get_hacker_error("ip addr show")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_uname_a_error(self):
+ result = self.cmd.get_hacker_error("uname -a")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_generic_error_for_unknown_command(self):
+ result = self.cmd.get_hacker_error("make coffee")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_case_insensitive(self):
+ result = self.cmd.get_hacker_error("SUDO apt-get install vim")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_with_translations(self):
+ """When translator returns a list, random.choice is used."""
+ bot = _make_bot()
+ bot.translator.get_value = Mock(return_value=["error1", "error2", "error3"])
+ cmd = HackerCommand(bot)
+ result = cmd.get_hacker_error("sudo foo")
+ assert result in ["error1", "error2", "error3"]
+
+ def test_with_empty_translation_falls_back(self):
+ """When translator returns empty list, fallback is used."""
+ bot = _make_bot()
+ bot.translator.get_value = Mock(return_value=[])
+ cmd = HackerCommand(bot)
+ result = cmd.get_hacker_error("sudo foo")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_with_non_list_translation_falls_back(self):
+ """When translator returns non-list, fallback is used."""
+ bot = _make_bot()
+ bot.translator.get_value = Mock(return_value="not a list")
+ cmd = HackerCommand(bot)
+ result = cmd.get_hacker_error("sudo foo")
+ assert isinstance(result, str)
+
+
+# ---------------------------------------------------------------------------
+# matches_keyword
+# ---------------------------------------------------------------------------
+
+class TestMatchesKeyword:
+ def test_disabled_never_matches(self):
+ cmd = HackerCommand(_make_bot(enabled=False))
+ assert cmd.matches_keyword(mock_message(content="sudo rm -rf /")) is False
+
+ def test_sudo_matches(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="sudo ls")) is True
+
+ def test_sudo_alone_matches(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="sudo")) is True
+
+ def test_ps_aux_matches(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="ps aux")) is True
+
+ def test_rm_rf_matches(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="rm -rf /")) is True
+
+ def test_rm_alone_matches(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="rm file.txt")) is True
+
+ def test_ls_l_exact_match(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="ls -l")) is True
+
+ def test_ls_la_exact_match(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="ls -la")) is True
+
+ def test_whoami_exact(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="whoami")) is True
+
+ def test_history_exact(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="history")) is True
+
+ def test_ssh_matches(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="ssh user@host")) is True
+
+ def test_non_hacker_command_no_match(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="ping")) is False
+
+ def test_with_exclamation_prefix(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="!sudo rm foo")) is True
+
+ def test_partial_match_no_space_no_match(self):
+ """'sudofoo' should not match 'sudo' prefix (no trailing space)."""
+ cmd = HackerCommand(_make_bot(enabled=True))
+ # "sudofoo" is not "sudo " nor exactly "sudo"
+ assert cmd.matches_keyword(mock_message(content="sudofoo")) is False
+
+ def test_ip_addr_matches(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="ip addr show")) is True
+
+ def test_ifconfig_matches(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="ifconfig eth0")) is True
+
+ def test_uname_a_exact_matches(self):
+ cmd = HackerCommand(_make_bot(enabled=True))
+ assert cmd.matches_keyword(mock_message(content="uname -a")) is True
+
+
+# ---------------------------------------------------------------------------
+# execute
+# ---------------------------------------------------------------------------
+
+class TestExecute:
+ def test_execute_disabled_returns_false(self):
+ bot = _make_bot(enabled=False)
+ cmd = HackerCommand(bot)
+ cmd.enabled = False
+ msg = mock_message(content="sudo foo")
+ result = _run(cmd.execute(msg))
+ assert result is False
+
+ def test_execute_enabled_sends_response(self):
+ bot = _make_bot(enabled=True)
+ cmd = HackerCommand(bot)
+ cmd.enabled = True
+ msg = mock_message(content="sudo foo")
+ result = _run(cmd.execute(msg))
+ assert result is True
+ bot.command_manager.send_response.assert_called_once()
+
+ def test_execute_with_exclamation_prefix(self):
+ bot = _make_bot(enabled=True)
+ cmd = HackerCommand(bot)
+ cmd.enabled = True
+ msg = mock_message(content="!sudo rm -rf /")
+ _run(cmd.execute(msg))
+ bot.command_manager.send_response.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# get_help_text
+# ---------------------------------------------------------------------------
+
+class TestGetHelpText:
+ def test_returns_description(self):
+ cmd = HackerCommand(_make_bot())
+ result = cmd.get_help_text()
+ assert result == cmd.description
diff --git a/tests/test_help_command.py b/tests/test_help_command.py
new file mode 100644
index 0000000..fa8d3da
--- /dev/null
+++ b/tests/test_help_command.py
@@ -0,0 +1,462 @@
+"""Tests for modules.commands.help_command — pure logic and integration paths."""
+
+import configparser
+from contextlib import contextmanager
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+
+from modules.commands.help_command import HelpCommand
+from tests.conftest import mock_message
+
+
+# ---------------------------------------------------------------------------
+# Bot factory
+# ---------------------------------------------------------------------------
+
+def _make_bot(enabled=True, commands=None):
+ """Create a minimal mock bot for HelpCommand tests."""
+ bot = MagicMock()
+ bot.logger = Mock()
+
+ config = configparser.ConfigParser()
+ config.add_section("Bot")
+ config.set("Bot", "bot_name", "TestBot")
+ config.add_section("Channels")
+ config.set("Channels", "monitor_channels", "general")
+ config.set("Channels", "respond_to_dms", "true")
+ config.add_section("Keywords")
+ if enabled:
+ config.add_section("Help_Command")
+ config.set("Help_Command", "enabled", "true")
+
+ bot.config = config
+ bot.translator = MagicMock()
+ bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
+ bot.translator.get_value = Mock(return_value=None)
+ bot.command_manager = MagicMock()
+ bot.command_manager.monitor_channels = ["general"]
+ bot.command_manager.send_response = MagicMock()
+
+ # Default empty commands dict
+ if commands is None:
+ bot.command_manager.commands = {}
+ else:
+ bot.command_manager.commands = commands
+
+ # Plugin loader with keyword_mappings
+ bot.command_manager.plugin_loader = MagicMock()
+ bot.command_manager.plugin_loader.keyword_mappings = {}
+
+ # DB manager with in-memory SQLite
+ import sqlite3
+ conn = sqlite3.connect(":memory:")
+ db = MagicMock()
+
+ @contextmanager
+ def _conn_ctx():
+ yield conn
+
+ db.connection = _conn_ctx
+ db.db_path = ":memory:"
+ bot.db_manager = db
+
+ return bot
+
+
+# ---------------------------------------------------------------------------
+# _format_commands_list_to_length (pure logic)
+# ---------------------------------------------------------------------------
+
+class TestFormatCommandsListToLength:
+ def setup_method(self):
+ self.cmd = HelpCommand(_make_bot())
+
+ def test_no_max_length_returns_all(self):
+ result = self.cmd._format_commands_list_to_length(["a", "b", "c"])
+ assert result == "a, b, c"
+
+ def test_max_length_zero_returns_all(self):
+ result = self.cmd._format_commands_list_to_length(["a", "b", "c"], max_length=0)
+ assert result == "a, b, c"
+
+ def test_empty_list_returns_empty(self):
+ result = self.cmd._format_commands_list_to_length([])
+ assert result == ""
+
+ def test_truncates_at_max_length(self):
+ # "a, b, c" = 7 chars; limit to 4 → only "a" + " (2 more)"
+ result = self.cmd._format_commands_list_to_length(["a", "b", "c"], max_length=10)
+ # Just verify it doesn't exceed max_length
+ assert len(result) <= 10
+
+ def test_all_fit_within_max_length(self):
+ result = self.cmd._format_commands_list_to_length(["ping", "wx"], max_length=100)
+ assert result == "ping, wx"
+
+ def test_suffix_appended_when_truncated(self):
+ names = ["alpha", "beta", "gamma", "delta", "epsilon"]
+ result = self.cmd._format_commands_list_to_length(names, max_length=20)
+ # Should contain "(N more)" suffix or be truncated
+ assert len(result) <= 20 or "more" in result
+
+ def test_single_item(self):
+ result = self.cmd._format_commands_list_to_length(["ping"])
+ assert result == "ping"
+
+ def test_single_item_exceeds_max_length(self):
+ result = self.cmd._format_commands_list_to_length(["verylongcommandname"], max_length=5)
+ # Can't fit, returns empty or truncated
+ assert isinstance(result, str)
+
+ def test_negative_max_length_returns_all(self):
+ result = self.cmd._format_commands_list_to_length(["a", "b"], max_length=-1)
+ assert result == "a, b"
+
+
+# ---------------------------------------------------------------------------
+# _is_command_valid_for_channel
+# ---------------------------------------------------------------------------
+
+class TestIsCommandValidForChannel:
+ def setup_method(self):
+ self.bot = _make_bot()
+ self.cmd = HelpCommand(self.bot)
+
+ def test_no_message_always_true(self):
+ mock_cmd = MagicMock()
+ assert self.cmd._is_command_valid_for_channel("ping", mock_cmd, None) is True
+
+ def test_channel_allowed_returns_true(self):
+ mock_cmd = MagicMock()
+ mock_cmd.is_channel_allowed = Mock(return_value=True)
+ msg = mock_message(content="help", channel="general")
+ assert self.cmd._is_command_valid_for_channel("ping", mock_cmd, msg) is True
+
+ def test_channel_not_allowed_returns_false(self):
+ mock_cmd = MagicMock()
+ mock_cmd.is_channel_allowed = Mock(return_value=False)
+ msg = mock_message(content="help", channel="general")
+ assert self.cmd._is_command_valid_for_channel("ping", mock_cmd, msg) is False
+
+ def test_no_is_channel_allowed_attribute(self):
+ mock_cmd = MagicMock(spec=[]) # No attributes
+ msg = mock_message(content="help", channel="general")
+ # Should not crash, should check _is_channel_trigger_allowed
+ result = self.cmd._is_command_valid_for_channel("ping", mock_cmd, msg)
+ assert isinstance(result, bool)
+
+ def test_channel_trigger_not_allowed(self):
+ mock_cmd = MagicMock()
+ mock_cmd.is_channel_allowed = Mock(return_value=True)
+ self.bot.command_manager._is_channel_trigger_allowed = Mock(return_value=False)
+ msg = mock_message(content="help", channel="restricted")
+ assert self.cmd._is_command_valid_for_channel("restricted_cmd", mock_cmd, msg) is False
+
+ def test_channel_trigger_allowed(self):
+ mock_cmd = MagicMock()
+ mock_cmd.is_channel_allowed = Mock(return_value=True)
+ self.bot.command_manager._is_channel_trigger_allowed = Mock(return_value=True)
+ msg = mock_message(content="help", channel="general")
+ assert self.cmd._is_command_valid_for_channel("ping", mock_cmd, msg) is True
+
+ def test_no_channel_trigger_check_attribute(self):
+ """When command_manager lacks _is_channel_trigger_allowed, still works."""
+ mock_cmd = MagicMock()
+ mock_cmd.is_channel_allowed = Mock(return_value=True)
+ del self.bot.command_manager._is_channel_trigger_allowed
+ msg = mock_message(content="help", channel="general")
+ result = self.cmd._is_command_valid_for_channel("ping", mock_cmd, msg)
+ assert result is True
+
+
+# ---------------------------------------------------------------------------
+# get_specific_help
+# ---------------------------------------------------------------------------
+
+class TestGetSpecificHelp:
+ def test_known_command_with_help_text(self):
+ bot = _make_bot()
+ mock_ping = MagicMock()
+ mock_ping.get_help_text = Mock(return_value="Ping the bot")
+ bot.command_manager.commands = {"ping": mock_ping}
+ cmd = HelpCommand(bot)
+ result = cmd.get_specific_help("ping")
+ assert "commands.help.specific" in result or result != ""
+
+ def test_known_command_help_text_no_message_param(self):
+ """Falls back to no-argument get_help_text when TypeError is raised."""
+ bot = _make_bot()
+ mock_cmd = MagicMock()
+ mock_cmd.get_help_text = Mock(side_effect=[TypeError("no param"), "Simple help"])
+ bot.command_manager.commands = {"foo": mock_cmd}
+ cmd = HelpCommand(bot)
+ result = cmd.get_specific_help("foo")
+ assert isinstance(result, str)
+
+ def test_unknown_command_returns_unknown_key(self):
+ bot = _make_bot()
+ bot.command_manager.commands = {}
+ cmd = HelpCommand(bot)
+ result = cmd.get_specific_help("unknowncmd")
+ assert "commands.help.unknown" in result
+
+ def test_alias_mapping_applied(self):
+ """Alias 'ping' maps to itself."""
+ bot = _make_bot()
+ mock_ping = MagicMock()
+ mock_ping.get_help_text = Mock(return_value="Pong!")
+ bot.command_manager.commands = {"ping": mock_ping}
+ cmd = HelpCommand(bot)
+ result = cmd.get_specific_help("ping")
+ assert isinstance(result, str)
+
+ def test_no_get_help_text_attribute(self):
+ """Command without get_help_text returns no_help key."""
+ bot = _make_bot()
+ mock_cmd = MagicMock(spec=[])
+ bot.command_manager.commands = {"bare": mock_cmd}
+ cmd = HelpCommand(bot)
+ result = cmd.get_specific_help("bare")
+ assert isinstance(result, str)
+
+
+# ---------------------------------------------------------------------------
+# can_execute
+# ---------------------------------------------------------------------------
+
+class TestCanExecute:
+ def test_enabled_true(self):
+ bot = _make_bot(enabled=True)
+ cmd = HelpCommand(bot)
+ msg = mock_message(content="help", channel="general")
+ assert cmd.can_execute(msg) is True
+
+ def test_enabled_false(self):
+ bot = _make_bot()
+ # Set the flag directly — the config section already exists from _make_bot
+ bot.config.set("Help_Command", "enabled", "false")
+ cmd = HelpCommand(bot)
+ cmd.help_enabled = False
+ msg = mock_message(content="help", channel="general")
+ assert cmd.can_execute(msg) is False
+
+
+# ---------------------------------------------------------------------------
+# get_help_text
+# ---------------------------------------------------------------------------
+
+class TestGetHelpText:
+ def test_returns_string(self):
+ cmd = HelpCommand(_make_bot())
+ result = cmd.get_help_text()
+ assert isinstance(result, str)
+
+
+class TestGetGeneralHelp:
+ """Tests for get_general_help() (lines 134-138)."""
+
+ def test_returns_string(self):
+ bot = _make_bot()
+ cmd = HelpCommand(bot)
+ result = cmd.get_general_help()
+ assert isinstance(result, str)
+
+ def test_includes_commands_help_key(self):
+ bot = _make_bot()
+ cmd = HelpCommand(bot)
+ result = cmd.get_general_help()
+ # Our mock translator returns keys — so should contain 'commands.help.general'
+ assert "commands.help" in result
+
+
+class TestGetAvailableCommandsListFiltered:
+ """Tests for channel-filtered command listing (line 185)."""
+
+ def test_channel_filter_excludes_invalid_commands(self):
+ bot = _make_bot()
+ mock_ping = MagicMock()
+ mock_ping.name = "ping"
+ mock_ping.is_channel_allowed = Mock(return_value=False) # Excluded
+ mock_wx = MagicMock()
+ mock_wx.name = "wx"
+ mock_wx.is_channel_allowed = Mock(return_value=True) # Included
+ bot.command_manager.commands = {"ping": mock_ping, "wx": mock_wx}
+ bot.command_manager._is_channel_trigger_allowed = Mock(return_value=True)
+ cmd = HelpCommand(bot)
+ msg = mock_message(content="help", channel="general")
+ result = cmd.get_available_commands_list(message=msg)
+ # ping is excluded; wx should be present
+ assert "wx" in result or "commands.help" in result # or just no crash
+
+ def test_command_in_stats_not_in_keyword_mappings(self):
+ """Commands returned from DB stats but not in keyword_mappings (lines 218-235)."""
+ import sqlite3
+ from contextlib import contextmanager
+
+ bot = _make_bot()
+ conn = sqlite3.connect(":memory:")
+ conn.execute("""
+ CREATE TABLE command_stats (
+ id INTEGER PRIMARY KEY,
+ timestamp INTEGER,
+ sender_id TEXT,
+ command_name TEXT,
+ channel TEXT,
+ is_dm BOOLEAN,
+ response_sent BOOLEAN
+ )
+ """)
+ # Add a command that's NOT in keyword_mappings but IS a primary command name
+ conn.execute("INSERT INTO command_stats VALUES (1, 1, 'u', 'unknown_cmd', 'g', 0, 1)")
+ conn.commit()
+
+ db = MagicMock()
+
+ @contextmanager
+ def _conn_ctx():
+ yield conn
+
+ db.connection = _conn_ctx
+ bot.db_manager = db
+
+ mock_cmd = MagicMock()
+ mock_cmd.name = "known"
+ bot.command_manager.commands = {"known": mock_cmd}
+ bot.command_manager.plugin_loader.keyword_mappings = {}
+
+ cmd = HelpCommand(bot)
+ result = cmd.get_available_commands_list()
+ assert isinstance(result, str)
+
+
+class TestFormatCommandsListSuffix:
+ """Tests for _format_commands_list_to_length with suffix that fits (line 294)."""
+
+ def test_suffix_fits_within_max(self):
+ cmd = HelpCommand(_make_bot())
+ # "ping" = 4 chars, " (1 more)" = 9 chars, "wx" doesn't fit; total "ping (1 more)" = 13
+ result = cmd._format_commands_list_to_length(["ping", "wx"], max_length=13)
+ assert "(1 more)" in result or "ping" in result
+
+ def test_suffix_appended_when_some_fit(self):
+ cmd = HelpCommand(_make_bot())
+ names = ["ab", "cd", "ef", "gh"]
+ result = cmd._format_commands_list_to_length(names, max_length=8)
+ assert isinstance(result, str)
+
+
+# ---------------------------------------------------------------------------
+# execute
+# ---------------------------------------------------------------------------
+
+class TestExecute:
+ def test_execute_returns_true(self):
+ import asyncio
+ bot = _make_bot()
+ cmd = HelpCommand(bot)
+ msg = mock_message(content="help", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+
+# ---------------------------------------------------------------------------
+# get_available_commands_list
+# ---------------------------------------------------------------------------
+
+class TestGetAvailableCommandsList:
+ def test_empty_commands_returns_empty(self):
+ bot = _make_bot()
+ bot.command_manager.commands = {}
+ cmd = HelpCommand(bot)
+ result = cmd.get_available_commands_list()
+ assert isinstance(result, str)
+
+ def test_with_commands_returns_names(self):
+ bot = _make_bot()
+ mock_ping = MagicMock()
+ mock_ping.name = "ping"
+ bot.command_manager.commands = {"ping": mock_ping}
+ cmd = HelpCommand(bot)
+ result = cmd.get_available_commands_list()
+ assert "ping" in result
+
+ def test_with_max_length(self):
+ bot = _make_bot()
+ mock_ping = MagicMock()
+ mock_ping.name = "ping"
+ bot.command_manager.commands = {"ping": mock_ping}
+ cmd = HelpCommand(bot)
+ result = cmd.get_available_commands_list(max_length=3)
+ assert len(result) <= 3 or "ping" in result
+
+ def test_with_message_filter(self):
+ bot = _make_bot()
+ mock_ping = MagicMock()
+ mock_ping.name = "ping"
+ mock_ping.is_channel_allowed = Mock(return_value=True)
+ bot.command_manager.commands = {"ping": mock_ping}
+ cmd = HelpCommand(bot)
+ msg = mock_message(content="help", channel="general")
+ result = cmd.get_available_commands_list(message=msg)
+ assert isinstance(result, str)
+
+ def test_with_stats_table_present(self):
+ """When command_stats table exists, commands are sorted by usage count."""
+ import sqlite3
+ from contextlib import contextmanager
+
+ bot = _make_bot()
+ conn = sqlite3.connect(":memory:")
+ conn.execute("""
+ CREATE TABLE command_stats (
+ id INTEGER PRIMARY KEY,
+ timestamp INTEGER,
+ sender_id TEXT,
+ command_name TEXT,
+ channel TEXT,
+ is_dm BOOLEAN,
+ response_sent BOOLEAN
+ )
+ """)
+ conn.execute("INSERT INTO command_stats (timestamp, sender_id, command_name, channel, is_dm, response_sent) VALUES (1, 'u1', 'ping', 'general', 0, 1)")
+ conn.execute("INSERT INTO command_stats (timestamp, sender_id, command_name, channel, is_dm, response_sent) VALUES (2, 'u1', 'ping', 'general', 0, 1)")
+ conn.commit()
+
+ db = MagicMock()
+
+ @contextmanager
+ def _conn_ctx():
+ yield conn
+
+ db.connection = _conn_ctx
+ bot.db_manager = db
+
+ mock_ping = MagicMock()
+ mock_ping.name = "ping"
+ bot.command_manager.commands = {"ping": mock_ping}
+ bot.command_manager.plugin_loader.keyword_mappings = {"ping": "ping"}
+
+ cmd = HelpCommand(bot)
+ result = cmd.get_available_commands_list()
+ assert "ping" in result
+
+ def test_db_exception_falls_back_gracefully(self):
+ """If DB raises, falls back to sorted command names."""
+ bot = _make_bot()
+ mock_ping = MagicMock()
+ mock_ping.name = "ping"
+ bot.command_manager.commands = {"ping": mock_ping}
+ bad_db = MagicMock()
+
+ @contextmanager
+ def _bad_conn():
+ raise Exception("DB down")
+ yield # noqa: unreachable
+
+ bad_db.connection = _bad_conn
+ bot.db_manager = bad_db
+
+ cmd = HelpCommand(bot)
+ result = cmd.get_available_commands_list()
+ assert isinstance(result, str)
diff --git a/tests/test_i18n.py b/tests/test_i18n.py
new file mode 100644
index 0000000..4e9a360
--- /dev/null
+++ b/tests/test_i18n.py
@@ -0,0 +1,203 @@
+"""Tests for modules.i18n — Translator class."""
+
+import json
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from modules.i18n import Translator
+
+
+class TestExtractBaseLanguage:
+ """Tests for _extract_base_language."""
+
+ def test_simple_code_unchanged(self):
+ t = Translator.__new__(Translator)
+ assert t._extract_base_language("en") == "en"
+
+ def test_hyphen_locale(self):
+ t = Translator.__new__(Translator)
+ assert t._extract_base_language("es-MX") == "es"
+
+ def test_underscore_locale(self):
+ t = Translator.__new__(Translator)
+ assert t._extract_base_language("es_ES") == "es"
+
+ def test_french(self):
+ t = Translator.__new__(Translator)
+ assert t._extract_base_language("fr") == "fr"
+
+
+class TestMergeTranslations:
+ """Tests for _merge_translations."""
+
+ def setup_method(self):
+ self.t = Translator.__new__(Translator)
+
+ def test_empty_primary_returns_copy_of_fallback(self):
+ fallback = {"a": "A", "b": "B"}
+ result = self.t._merge_translations({}, fallback)
+ assert result == fallback
+ assert result is not fallback # should be a copy
+
+ def test_primary_overrides_fallback(self):
+ primary = {"a": "OVERRIDE"}
+ fallback = {"a": "original", "b": "keep_me"}
+ result = self.t._merge_translations(primary, fallback)
+ assert result["a"] == "OVERRIDE"
+ assert result["b"] == "keep_me"
+
+ def test_nested_dicts_merged_recursively(self):
+ primary = {"grp": {"x": "X_override"}}
+ fallback = {"grp": {"x": "X_orig", "y": "Y_orig"}}
+ result = self.t._merge_translations(primary, fallback)
+ assert result["grp"]["x"] == "X_override"
+ assert result["grp"]["y"] == "Y_orig"
+
+ def test_flat_override_wins_over_nested_fallback(self):
+ primary = {"grp": "flat_string"}
+ fallback = {"grp": {"x": "nested"}}
+ result = self.t._merge_translations(primary, fallback)
+ assert result["grp"] == "flat_string"
+
+
+class TestTranslatorWithRealFiles:
+ """Tests that use actual translation files (if available)."""
+
+ def test_english_fallback_returns_key_when_missing(self, tmp_path):
+ en = {"commands": {"ping": {"response": "Pong!"}}}
+ (tmp_path / "en.json").write_text(json.dumps(en))
+ t = Translator(language="en", translation_path=str(tmp_path))
+ assert t.translate("commands.ping.response") == "Pong!"
+
+ def test_missing_key_returns_key(self, tmp_path):
+ en = {"hello": "world"}
+ (tmp_path / "en.json").write_text(json.dumps(en))
+ t = Translator(language="en", translation_path=str(tmp_path))
+ assert t.translate("commands.nonexistent.key") == "commands.nonexistent.key"
+
+ def test_translate_with_format_kwargs(self, tmp_path):
+ en = {"greeting": "Hello, {name}!"}
+ (tmp_path / "en.json").write_text(json.dumps(en))
+ t = Translator(language="en", translation_path=str(tmp_path))
+ assert t.translate("greeting", name="World") == "Hello, World!"
+
+ def test_format_failure_returns_unformatted(self, tmp_path):
+ en = {"greeting": "Hello, {name}!"}
+ (tmp_path / "en.json").write_text(json.dumps(en))
+ t = Translator(language="en", translation_path=str(tmp_path))
+ # Missing kwarg -> formatting fails -> return unformatted
+ result = t.translate("greeting")
+ # No kwargs: should just return the value without formatting
+ assert "Hello" in result or result == "Hello, {name}!"
+
+ def test_fallback_to_english_when_key_missing_in_locale(self, tmp_path):
+ en = {"only_in_english": "English value"}
+ es = {"other_key": "Spanish value"}
+ (tmp_path / "en.json").write_text(json.dumps(en))
+ (tmp_path / "es.json").write_text(json.dumps(es))
+ t = Translator(language="es", translation_path=str(tmp_path))
+ assert t.translate("only_in_english") == "English value"
+
+ def test_locale_overrides_base(self, tmp_path):
+ en = {"greeting": "Hello"}
+ es = {"greeting": "Hola"}
+ es_mx = {"greeting": "Que tal"}
+ (tmp_path / "en.json").write_text(json.dumps(en))
+ (tmp_path / "es.json").write_text(json.dumps(es))
+ (tmp_path / "es-MX.json").write_text(json.dumps(es_mx))
+ t = Translator(language="es-MX", translation_path=str(tmp_path))
+ assert t.translate("greeting") == "Que tal"
+
+ def test_get_available_languages(self, tmp_path):
+ for lang in ["en", "es", "fr"]:
+ (tmp_path / f"{lang}.json").write_text("{}")
+ t = Translator(language="en", translation_path=str(tmp_path))
+ langs = t.get_available_languages()
+ assert sorted(langs) == ["en", "es", "fr"]
+
+ def test_get_available_languages_missing_dir(self, tmp_path):
+ t = Translator(language="en", translation_path=str(tmp_path / "nonexistent"))
+ assert t.get_available_languages() == []
+
+ def test_get_value_returns_raw_value(self, tmp_path):
+ en = {"commands": {"list": ["a", "b", "c"]}}
+ (tmp_path / "en.json").write_text(json.dumps(en))
+ t = Translator(language="en", translation_path=str(tmp_path))
+ val = t.get_value("commands.list")
+ assert val == ["a", "b", "c"]
+
+ def test_get_value_missing_key_returns_none(self, tmp_path):
+ en = {"key": "value"}
+ (tmp_path / "en.json").write_text(json.dumps(en))
+ t = Translator(language="en", translation_path=str(tmp_path))
+ assert t.get_value("no.such.key") is None
+
+ def test_reload_picks_up_new_content(self, tmp_path):
+ en_file = tmp_path / "en.json"
+ en_file.write_text(json.dumps({"msg": "original"}))
+ t = Translator(language="en", translation_path=str(tmp_path))
+ assert t.translate("msg") == "original"
+ en_file.write_text(json.dumps({"msg": "updated"}))
+ t.reload()
+ assert t.translate("msg") == "updated"
+
+ def test_invalid_json_returns_empty(self, tmp_path):
+ en_file = tmp_path / "en.json"
+ en_file.write_text("{invalid json")
+ t = Translator(language="en", translation_path=str(tmp_path))
+ # Should not crash; translations will be empty
+ result = t.translate("any.key")
+ assert result == "any.key"
+
+ def test_translate_non_string_value_returns_key(self, tmp_path):
+ en = {"items": ["a", "b"]}
+ (tmp_path / "en.json").write_text(json.dumps(en))
+ t = Translator(language="en", translation_path=str(tmp_path))
+ # List value should return key, not the list
+ assert t.translate("items") == "items"
+
+ def test_load_file_non_json_exception(self, tmp_path):
+ """A non-JSONDecodeError exception in _load_file returns empty dict."""
+ en_file = tmp_path / "en.json"
+ en_file.write_text('{"key": "value"}')
+ t = Translator(language="en", translation_path=str(tmp_path))
+ # Now simulate generic exception by patching open
+ from unittest.mock import patch, mock_open
+ with patch("builtins.open", side_effect=PermissionError("denied")):
+ result = t._load_file("en")
+ assert result == {}
+
+ def test_translate_fallback_inner_loop_executes(self, tmp_path):
+ """When outer loop misses, inner fallback loop finds nested key (line 148)."""
+ # English has nested key; target language has different top-level key
+ en = {"grp": {"deep": "found"}}
+ xx = {"other": "x"} # no 'grp' key
+ (tmp_path / "en.json").write_text(json.dumps(en))
+ (tmp_path / "xx.json").write_text(json.dumps(xx))
+ t = Translator(language="xx", translation_path=str(tmp_path))
+ # 'grp.deep' will miss in xx translations (outer loop fails on 'grp'),
+ # then inner fallback loop finds 'grp' in English (line 148), then 'deep'
+ result = t.translate("grp.deep")
+ assert result == "found"
+
+ def test_translate_format_failure_returns_unformatted(self, tmp_path):
+ """When .format(**kwargs) raises, return the unformatted value (lines 158-160)."""
+ en = {"greeting": "Hello, {name}!"}
+ (tmp_path / "en.json").write_text(json.dumps(en))
+ t = Translator(language="en", translation_path=str(tmp_path))
+ # Pass a wrong kwarg to trigger KeyError in format
+ result = t.translate("greeting", wrong_key="x")
+ # Should return unformatted string, not crash
+ assert result == "Hello, {name}!"
+
+ def test_get_value_fallback_break(self, tmp_path):
+ """get_value fallback inner loop completes and hits break (line 210)."""
+ en = {"grp": {"val": "found"}}
+ xx = {"other": "x"}
+ (tmp_path / "en.json").write_text(json.dumps(en))
+ (tmp_path / "xx.json").write_text(json.dumps(xx))
+ t = Translator(language="xx", translation_path=str(tmp_path))
+ result = t.get_value("grp.val")
+ assert result == "found"
diff --git a/tests/test_joke_command.py b/tests/test_joke_command.py
new file mode 100644
index 0000000..adc80b5
--- /dev/null
+++ b/tests/test_joke_command.py
@@ -0,0 +1,321 @@
+"""Tests for modules.commands.joke_command — pure logic functions."""
+
+import configparser
+from datetime import datetime
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+
+from modules.commands.joke_command import JokeCommand
+from tests.conftest import mock_message
+
+
+def _make_bot(seasonal=True, long_jokes=False):
+ bot = MagicMock()
+ bot.logger = Mock()
+ config = configparser.ConfigParser()
+ config.add_section("Bot")
+ config.set("Bot", "bot_name", "TestBot")
+ config.add_section("Channels")
+ config.set("Channels", "monitor_channels", "general")
+ config.set("Channels", "respond_to_dms", "true")
+ config.add_section("Keywords")
+ config.add_section("Joke_Command")
+ config.set("Joke_Command", "enabled", "true")
+ config.set("Joke_Command", "seasonal_jokes", str(seasonal).lower())
+ config.set("Joke_Command", "long_jokes", str(long_jokes).lower())
+ bot.config = config
+ bot.translator = MagicMock()
+ bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
+ bot.command_manager = MagicMock()
+ bot.command_manager.monitor_channels = ["general"]
+ return bot
+
+
+class TestGetSeasonalDefault:
+ """Tests for get_seasonal_default."""
+
+ def test_october_returns_spooky(self):
+ cmd = JokeCommand(_make_bot(seasonal=True))
+ # datetime is imported inside the function, patch datetime.now at its source
+ with patch("datetime.datetime") as mock_dt:
+ mock_dt.now.return_value = datetime(2024, 10, 15)
+ result = cmd.get_seasonal_default()
+ assert result == "Spooky"
+
+ def test_december_returns_christmas(self):
+ cmd = JokeCommand(_make_bot(seasonal=True))
+ with patch("datetime.datetime") as mock_dt:
+ mock_dt.now.return_value = datetime(2024, 12, 15)
+ result = cmd.get_seasonal_default()
+ assert result == "Christmas"
+
+ def test_other_month_returns_none(self):
+ cmd = JokeCommand(_make_bot(seasonal=True))
+ with patch("datetime.datetime") as mock_dt:
+ mock_dt.now.return_value = datetime(2024, 6, 15)
+ result = cmd.get_seasonal_default()
+ assert result is None
+
+ def test_seasonal_disabled_returns_none(self):
+ cmd = JokeCommand(_make_bot(seasonal=False))
+ # Even in October, seasonal disabled returns None
+ result = cmd.get_seasonal_default()
+ assert result is None
+
+
+class TestFormatJoke:
+ """Tests for format_joke."""
+
+ def setup_method(self):
+ self.cmd = JokeCommand(_make_bot())
+
+ def test_single_type_joke(self):
+ data = {"type": "single", "joke": "Why so funny?"}
+ result = self.cmd.format_joke(data)
+ assert "🎭" in result
+ assert "Why so funny?" in result
+
+ def test_twopart_joke(self):
+ data = {"type": "twopart", "setup": "Why?", "delivery": "Because!"}
+ result = self.cmd.format_joke(data)
+ assert "🎭" in result
+ assert "Why?" in result
+ assert "Because!" in result
+
+ def test_empty_single_joke_returns_fallback(self):
+ data = {"type": "single", "joke": ""}
+ result = self.cmd.format_joke(data)
+ assert "🎭" in result
+
+ def test_unknown_type_fallback(self):
+ data = {"type": "weird", "joke": "Some joke"}
+ result = self.cmd.format_joke(data)
+ assert "🎭" in result
+ assert "Some joke" in result
+
+
+class TestSplitJoke:
+ """Tests for split_joke."""
+
+ def setup_method(self):
+ self.cmd = JokeCommand(_make_bot())
+
+ def test_splits_at_newline(self):
+ joke = "🎭 Setup part.\n\nDelivery part."
+ result = self.cmd.split_joke(joke)
+ assert len(result) == 2
+
+ def test_each_part_has_emoji(self):
+ joke = "🎭 First sentence. Second sentence here."
+ result = self.cmd.split_joke(joke)
+ for part in result:
+ assert "🎭" in part
+
+ def test_no_split_point_splits_midpoint(self):
+ joke = "🎭 abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz extra"
+ result = self.cmd.split_joke(joke)
+ assert len(result) == 2
+
+
+class TestIsDarkJokeRequest:
+ """Tests for is_dark_joke_request."""
+
+ def setup_method(self):
+ self.cmd = JokeCommand(_make_bot())
+
+ def test_dark_category_is_dark(self):
+ msg = mock_message(content="joke dark")
+ assert self.cmd.is_dark_joke_request(msg) is True
+
+ def test_other_category_not_dark(self):
+ msg = mock_message(content="joke programming")
+ assert self.cmd.is_dark_joke_request(msg) is False
+
+ def test_no_category_not_dark(self):
+ msg = mock_message(content="joke")
+ assert self.cmd.is_dark_joke_request(msg) is False
+
+ def test_exclamation_prefix(self):
+ msg = mock_message(content="!joke dark")
+ assert self.cmd.is_dark_joke_request(msg) is True
+
+
+class TestJokeCanExecute:
+ """Tests for can_execute."""
+
+ def test_dark_joke_in_channel_blocked(self):
+ bot = _make_bot()
+ cmd = JokeCommand(bot)
+ msg = mock_message(content="joke dark", channel="general", is_dm=False)
+ # Dark jokes in public channels should be blocked
+ assert cmd.can_execute(msg) is False
+
+ def test_dark_joke_in_dm_allowed(self):
+ bot = _make_bot()
+ cmd = JokeCommand(bot)
+ msg = mock_message(content="joke dark", is_dm=True)
+ assert cmd.can_execute(msg) is True
+
+ def test_disabled_command_blocked(self):
+ bot = _make_bot()
+ bot.config.set("Joke_Command", "enabled", "false")
+ cmd = JokeCommand(bot)
+ msg = mock_message(content="joke", channel="general")
+ assert cmd.can_execute(msg) is False
+
+
+class TestJokeMatchesKeyword:
+ def test_joke_matches(self):
+ cmd = JokeCommand(_make_bot())
+ assert cmd.matches_keyword(mock_message(content="joke")) is True
+
+ def test_jokes_matches(self):
+ cmd = JokeCommand(_make_bot())
+ assert cmd.matches_keyword(mock_message(content="jokes")) is True
+
+ def test_joke_with_category_matches(self):
+ cmd = JokeCommand(_make_bot())
+ assert cmd.matches_keyword(mock_message(content="joke programming")) is True
+
+ def test_other_does_not_match(self):
+ cmd = JokeCommand(_make_bot())
+ assert cmd.matches_keyword(mock_message(content="dadjoke")) is False
+
+ def test_exclamation_prefix_matches(self):
+ cmd = JokeCommand(_make_bot())
+ assert cmd.matches_keyword(mock_message(content="!joke")) is True
+
+
+class TestJokeGetHelpText:
+ def test_public_channel_excludes_dark(self):
+ cmd = JokeCommand(_make_bot())
+ msg = mock_message(content="joke", channel="general", is_dm=False)
+ result = cmd.get_help_text(message=msg)
+ assert "dark" not in result
+ assert "joke" in result.lower()
+
+ def test_dm_includes_dark(self):
+ cmd = JokeCommand(_make_bot())
+ msg = mock_message(content="joke", is_dm=True)
+ result = cmd.get_help_text(message=msg)
+ assert "dark" in result
+
+ def test_no_message_returns_all_categories(self):
+ cmd = JokeCommand(_make_bot())
+ result = cmd.get_help_text()
+ assert "joke" in result.lower()
+
+
+class TestJokeFormatUnknownType:
+ def test_unknown_type_with_no_text_returns_fallback(self):
+ cmd = JokeCommand(_make_bot())
+ data = {"type": "weird"}
+ result = cmd.format_joke(data)
+ assert "🎭" in result
+
+ def test_twopart_only_setup(self):
+ cmd = JokeCommand(_make_bot())
+ data = {"type": "twopart", "setup": "Why?", "delivery": ""}
+ result = cmd.format_joke(data)
+ assert "🎭" in result
+ assert "Why?" in result
+
+
+class TestJokeExecute:
+ def test_execute_invalid_category_sends_error(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+
+ bot = _make_bot()
+ bot.command_manager.send_response = AsyncMock(return_value=True)
+ cmd = JokeCommand(bot)
+ msg = mock_message(content="joke badcategory", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+ bot.command_manager.send_response.assert_called_once()
+
+ def test_execute_no_category_with_joke(self):
+ import asyncio
+ from unittest.mock import AsyncMock, patch
+
+ bot = _make_bot()
+ bot.command_manager.send_response = AsyncMock(return_value=True)
+ cmd = JokeCommand(bot)
+ msg = mock_message(content="joke", channel="general")
+
+ with patch.object(cmd, "get_joke_with_length_handling", new_callable=AsyncMock,
+ return_value={"type": "single", "joke": "A short joke!"}):
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+ def test_execute_returns_true_when_no_joke(self):
+ import asyncio
+ from unittest.mock import AsyncMock, patch
+
+ bot = _make_bot()
+ bot.command_manager.send_response = AsyncMock(return_value=True)
+ cmd = JokeCommand(bot)
+ msg = mock_message(content="joke", channel="general")
+
+ with patch.object(cmd, "get_joke_with_length_handling", new_callable=AsyncMock,
+ return_value=None):
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+ def test_execute_dark_category_no_joke(self):
+ import asyncio
+ from unittest.mock import AsyncMock, patch
+
+ bot = _make_bot()
+ bot.command_manager.send_response = AsyncMock(return_value=True)
+ cmd = JokeCommand(bot)
+ msg = mock_message(content="joke dark", is_dm=True)
+
+ with patch.object(cmd, "get_joke_with_length_handling", new_callable=AsyncMock,
+ return_value=None):
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+ def test_execute_handles_exception(self):
+ import asyncio
+ from unittest.mock import AsyncMock, patch
+
+ bot = _make_bot()
+ bot.command_manager.send_response = AsyncMock(return_value=True)
+ cmd = JokeCommand(bot)
+ msg = mock_message(content="joke", channel="general")
+
+ with patch.object(cmd, "get_joke_with_length_handling", new_callable=AsyncMock,
+ side_effect=Exception("API error")):
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+
+class TestSendJokeWithLengthHandling:
+ def test_short_joke_sends_single_message(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+
+ bot = _make_bot()
+ bot.command_manager.send_response = AsyncMock(return_value=True)
+ cmd = JokeCommand(bot)
+ msg = mock_message(content="joke")
+
+ joke_data = {"type": "single", "joke": "Short!"}
+ asyncio.run(cmd.send_joke_with_length_handling(msg, joke_data))
+ bot.command_manager.send_response.assert_called_once()
+
+ def test_long_joke_split(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+
+ bot = _make_bot()
+ bot.command_manager.send_response = AsyncMock(return_value=True)
+ cmd = JokeCommand(bot)
+ msg = mock_message(content="joke")
+
+ long_joke = "x" * 50 + ". " + "y" * 80
+ joke_data = {"type": "single", "joke": long_joke}
+ asyncio.run(cmd.send_joke_with_length_handling(msg, joke_data))
+ assert bot.command_manager.send_response.call_count >= 1
diff --git a/tests/test_message_handler.py b/tests/test_message_handler.py
index bdc2647..7727f60 100644
--- a/tests/test_message_handler.py
+++ b/tests/test_message_handler.py
@@ -812,3 +812,735 @@ class TestHandleChannelMessage:
with patch.object(handler, "_debug_decode_message_path", side_effect=RuntimeError("boom")):
await handler.handle_channel_message(event)
handler.logger.error.assert_called()
+
+
+# ---------------------------------------------------------------------------
+# Packet construction helpers (used across multiple test classes below)
+# ---------------------------------------------------------------------------
+#
+# MeshCore packet binary layout (from decode_meshcore_packet / Packet.cpp):
+#
+# header (1 byte):
+# bits 7-6 = payload_version (0 = VER_1, must be 0)
+# bits 5-2 = payload_type (ADVERT=4, TXT_MSG=2, GRP_TXT=5, TRACE=9, …)
+# bits 1-0 = route_type (TRANSPORT_FLOOD=0, FLOOD=1, DIRECT=2, TRANSPORT_DIRECT=3)
+# [transport bytes: 4 bytes if route_type is TRANSPORT_FLOOD or TRANSPORT_DIRECT]
+# path_len_byte (1 byte):
+# low 6 bits = hop_count
+# high 2 bits = size_code (bytes_per_hop = size_code + 1)
+# => path_byte_length = hop_count * bytes_per_hop
+# path bytes (path_byte_length bytes)
+# payload bytes (remainder)
+#
+# header = (0 << 6) | (payload_type << 2) | route_type
+#
+# Pre-computed examples used in tests:
+# FLOOD(1)+ADVERT(4), 0 hops: header=0x11, path_len=0x00 → "1100"
+# FLOOD(1)+TXT_MSG(2), 0 hops: header=0x09, path_len=0x00 → "0900"
+# FLOOD(1)+ADVERT(4), 2 hops 1-byte (AB,CD): header=0x11, path_len=0x02 → "110202abcdfeed"
+# DIRECT(2)+GRP_TXT(5), 0 hops: header=0x16, path_len=0x00 → "1600"
+# TRANSPORT_FLOOD(0)+TXT_MSG(2), 4-byte transport, 0 hops: header=0x08 → "0801020304 00 ff"
+# FLOOD(1)+TRACE(9), 2 hops, payload: header=0x25
+#
+# Advert payload format (for parse_advert, from AdvertDataHelpers.h):
+# bytes 0-31: public_key (32 bytes)
+# bytes 32-35: timestamp (uint32 little-endian)
+# bytes 36-99: signature (64 bytes)
+# byte 100: flags_byte (app_data[0])
+# bits 3-0 = adv_type (CHAT=1, REPEATER=2, ROOM=3, SENSOR=4)
+# bit 4 = ADV_LATLON_MASK (has location: 8 bytes lat+lon)
+# bit 5 = ADV_FEAT1_MASK (has feat1: 2 bytes)
+# bit 6 = ADV_FEAT2_MASK (has feat2: 2 bytes)
+# bit 7 = ADV_NAME_MASK (has name: remaining bytes as UTF-8)
+# bytes 101+: optional location / feat1 / feat2 / name
+
+def _make_advert_payload(
+ flags_byte: int,
+ *,
+ pub_key: bytes = b"\xaa" * 32,
+ timestamp: int = 1700000000,
+ signature: bytes = b"\xbb" * 64,
+ location_lat_raw: int = 0,
+ location_lon_raw: int = 0,
+ feat1: int = 0,
+ feat2: int = 0,
+ name: str = "",
+) -> bytes:
+ """Build a minimal valid advert payload byte string for parse_advert()."""
+ # Header: pub_key (32) + timestamp (4 little-endian) + signature (64) = 100 bytes
+ ts_bytes = timestamp.to_bytes(4, "little")
+ header = pub_key[:32] + ts_bytes + signature[:64]
+ assert len(header) == 100
+
+ app_data = bytes([flags_byte])
+
+ # Optional location (8 bytes): lat (int32 LE) + lon (int32 LE)
+ if flags_byte & 0x10:
+ app_data += location_lat_raw.to_bytes(4, "little", signed=True)
+ app_data += location_lon_raw.to_bytes(4, "little", signed=True)
+
+ # Optional feat1 (2 bytes)
+ if flags_byte & 0x20:
+ app_data += feat1.to_bytes(2, "little")
+
+ # Optional feat2 (2 bytes)
+ if flags_byte & 0x40:
+ app_data += feat2.to_bytes(2, "little")
+
+ # Optional name (variable length UTF-8)
+ if flags_byte & 0x80:
+ app_data += name.encode("utf-8")
+
+ return header + app_data
+
+
+def _make_packet_hex(
+ payload_type: int,
+ route_type: int,
+ path_bytes: bytes = b"",
+ payload_bytes: bytes = b"\xfe",
+ *,
+ hop_count: int = 0,
+ bytes_per_hop: int = 1,
+ transport: bytes = b"",
+) -> str:
+ """Build a valid MeshCore packet hex string for decode_meshcore_packet()."""
+ header = (0 << 6) | (payload_type << 2) | route_type
+ # path_len_byte: high 2 bits = size_code (bytes_per_hop - 1), low 6 bits = hop_count
+ size_code = bytes_per_hop - 1
+ path_len_byte = (size_code << 6) | (hop_count & 0x3F)
+ pkt = bytes([header]) + transport + bytes([path_len_byte]) + path_bytes + payload_bytes
+ return pkt.hex()
+
+
+# ---------------------------------------------------------------------------
+# decode_meshcore_packet
+# ---------------------------------------------------------------------------
+
+class TestDecodeMeshcorePacket:
+ """Tests for MessageHandler.decode_meshcore_packet() — pure hex/binary parsing."""
+
+ # --- invalid / edge-case inputs ---
+
+ def test_none_raw_hex_returns_none(self, handler):
+ result = handler.decode_meshcore_packet(None)
+ assert result is None
+
+ def test_empty_raw_hex_returns_none(self, handler):
+ result = handler.decode_meshcore_packet("")
+ assert result is None
+
+ def test_single_byte_too_short_returns_none(self, handler):
+ # 1 byte only → fails minimum size check (< 2)
+ result = handler.decode_meshcore_packet("11")
+ assert result is None
+
+ def test_invalid_hex_string_raises_or_returns_none(self, handler):
+ # "ZZZZZZ" is not valid hex. bytes.fromhex() raises ValueError inside the try-block;
+ # the except handler then tries to reference the unbound local `byte_data` in its log
+ # message, which produces an UnboundLocalError that propagates out.
+ # Either behaviour (None return or propagated exception) is acceptable here; the
+ # important thing is that the method does NOT silently succeed.
+ try:
+ result = handler.decode_meshcore_packet("ZZZZZZ")
+ assert result is None
+ except (ValueError, UnboundLocalError):
+ pass # expected — source-level bug causes exception to propagate
+
+ def test_0x_prefix_stripped(self, handler):
+ # Prepend '0x' — should be stripped transparently
+ hex_no_prefix = _make_packet_hex(4, 1, payload_bytes=b"\xde")
+ result = handler.decode_meshcore_packet("0x" + hex_no_prefix)
+ assert result is not None
+ assert result["route_type_name"] == "FLOOD"
+ assert result["payload_type_name"] == "ADVERT"
+
+ def test_payload_hex_preferred_over_raw_hex(self, handler):
+ # raw_hex encodes a TXT_MSG, payload_hex encodes an ADVERT
+ raw_txt = _make_packet_hex(2, 1, payload_bytes=b"\x01")
+ raw_adv = _make_packet_hex(4, 1, payload_bytes=b"\x02")
+ result = handler.decode_meshcore_packet(raw_txt, payload_hex=raw_adv)
+ # Should decode from payload_hex (ADVERT), not raw_hex (TXT_MSG)
+ assert result["payload_type_name"] == "ADVERT"
+
+ def test_unknown_payload_version_returns_none(self, handler):
+ # Build a packet with payload_version = 1 (VER_2) in bits 7-6
+ payload_type = 2 # TXT_MSG
+ route_type = 1 # FLOOD
+ header = (1 << 6) | (payload_type << 2) | route_type # version bits = 01
+ path_len_byte = 0x00
+ pkt = bytes([header, path_len_byte, 0xAA])
+ result = handler.decode_meshcore_packet(pkt.hex())
+ assert result is None
+
+ # --- FLOOD route ---
+
+ def test_flood_advert_no_path(self, handler):
+ hex_str = _make_packet_hex(4, 1, payload_bytes=b"\xde\xad")
+ result = handler.decode_meshcore_packet(hex_str)
+ assert result is not None
+ assert result["route_type_name"] == "FLOOD"
+ assert result["payload_type_name"] == "ADVERT"
+ assert result["route_type"] == 1
+ assert result["payload_type"] == 4
+ assert result["payload_version"] == 0
+ assert result["has_transport_codes"] is False
+ assert result["transport_codes"] is None
+ assert result["path_len"] == 0
+ assert result["path"] == []
+ assert result["path_hex"] == ""
+ assert result["payload_hex"] == "dead"
+
+ def test_flood_txt_msg_no_path(self, handler):
+ hex_str = _make_packet_hex(2, 1, payload_bytes=b"\x48\x69")
+ result = handler.decode_meshcore_packet(hex_str)
+ assert result is not None
+ assert result["payload_type_name"] == "TXT_MSG"
+ assert result["route_type_name"] == "FLOOD"
+
+ def test_flood_advert_two_hops_one_byte(self, handler):
+ path = bytes([0xAB, 0xCD])
+ hex_str = _make_packet_hex(
+ 4, 1,
+ path_bytes=path,
+ payload_bytes=b"\xEE",
+ hop_count=2,
+ bytes_per_hop=1,
+ )
+ result = handler.decode_meshcore_packet(hex_str)
+ assert result is not None
+ assert result["path_len"] == 2
+ assert result["path_byte_length"] == 2
+ assert result["bytes_per_hop"] == 1
+ assert result["path_hex"] == "abcd"
+ assert result["path"] == ["AB", "CD"]
+
+ def test_flood_advert_two_hops_two_bytes(self, handler):
+ # 2 hops, 2 bytes each → 4 path bytes
+ path = bytes([0x01, 0x02, 0xAB, 0xCD])
+ hex_str = _make_packet_hex(
+ 4, 1,
+ path_bytes=path,
+ payload_bytes=b"\xEE",
+ hop_count=2,
+ bytes_per_hop=2,
+ )
+ result = handler.decode_meshcore_packet(hex_str)
+ assert result is not None
+ assert result["bytes_per_hop"] == 2
+ assert result["path_byte_length"] == 4
+ assert result["path_len"] == 2
+ assert result["path"] == ["0102", "ABCD"]
+
+ # --- DIRECT route ---
+
+ def test_direct_grp_txt_no_path(self, handler):
+ hex_str = _make_packet_hex(5, 2, payload_bytes=b"\x01\x02\x03")
+ result = handler.decode_meshcore_packet(hex_str)
+ assert result is not None
+ assert result["route_type_name"] == "DIRECT"
+ assert result["payload_type_name"] == "GRP_TXT"
+ assert result["has_transport_codes"] is False
+
+ # --- TRANSPORT_FLOOD route (has 4 transport bytes) ---
+
+ def test_transport_flood_has_transport_codes(self, handler):
+ transport = bytes([0x01, 0x02, 0x03, 0x04])
+ hex_str = _make_packet_hex(
+ 2, 0,
+ payload_bytes=b"\xFF",
+ transport=transport,
+ )
+ result = handler.decode_meshcore_packet(hex_str)
+ assert result is not None
+ assert result["route_type_name"] == "TRANSPORT_FLOOD"
+ assert result["has_transport_codes"] is True
+ assert result["transport_codes"] is not None
+ assert result["transport_codes"]["code1"] == 0x0201
+ assert result["transport_codes"]["code2"] == 0x0403
+
+ def test_transport_direct_has_transport_codes(self, handler):
+ transport = bytes([0x0A, 0x0B, 0x0C, 0x0D])
+ hex_str = _make_packet_hex(
+ 4, 3,
+ payload_bytes=b"\xAA",
+ transport=transport,
+ )
+ result = handler.decode_meshcore_packet(hex_str)
+ assert result is not None
+ assert result["route_type_name"] == "TRANSPORT_DIRECT"
+ assert result["has_transport_codes"] is True
+
+ # --- Too-short packet after stripping transport ---
+
+ def test_too_short_for_path_len_returns_none(self, handler):
+ # TRANSPORT_FLOOD needs 1 (header) + 4 (transport) + 1 (path_len) = 6 bytes minimum
+ # Provide only header + 4 transport bytes (no path_len byte)
+ header = (0 << 6) | (2 << 2) | 0 # TRANSPORT_FLOOD + TXT_MSG
+ pkt = bytes([header, 0x01, 0x02, 0x03, 0x04]) # 5 bytes, missing path_len
+ result = handler.decode_meshcore_packet(pkt.hex())
+ assert result is None
+
+ def test_path_bytes_exceed_available_data_returns_none(self, handler):
+ # Claim 3 hops (3 path bytes) but only provide 2 in the packet
+ header = (0 << 6) | (4 << 2) | 1 # FLOOD + ADVERT
+ path_len_byte = 0x03 # 3 hops, 1 byte each
+ pkt = bytes([header, path_len_byte, 0xAA, 0xBB]) # only 2 path bytes
+ result = handler.decode_meshcore_packet(pkt.hex())
+ assert result is None
+
+ # --- All standard payload types decode without crashing ---
+
+ def test_all_payload_types_decode(self, handler):
+ known_types = {
+ 0x00: "REQ",
+ 0x01: "RESPONSE",
+ 0x02: "TXT_MSG",
+ 0x03: "ACK",
+ 0x04: "ADVERT",
+ 0x05: "GRP_TXT",
+ 0x06: "GRP_DATA",
+ 0x07: "ANON_REQ",
+ 0x08: "PATH",
+ 0x09: "TRACE",
+ 0x0A: "MULTIPART",
+ 0x0F: "RAW_CUSTOM",
+ }
+ for pt_val, expected_name in known_types.items():
+ hex_str = _make_packet_hex(pt_val, 1, payload_bytes=b"\x01\x02")
+ result = handler.decode_meshcore_packet(hex_str)
+ assert result is not None, f"Expected non-None for payload_type 0x{pt_val:02x}"
+ assert result["payload_type_name"] == expected_name
+
+ # --- Return dict structure completeness ---
+
+ def test_return_dict_has_expected_keys(self, handler):
+ hex_str = _make_packet_hex(4, 1, payload_bytes=b"\xAA")
+ result = handler.decode_meshcore_packet(hex_str)
+ required_keys = {
+ "header", "route_type", "route_type_name", "payload_type", "payload_type_name",
+ "payload_version", "route_type_enum", "payload_type_enum", "payload_version_enum",
+ "has_transport_codes", "transport_codes", "transport_size",
+ "path_len", "path_byte_length", "bytes_per_hop",
+ "path_info", "path", "path_hex", "payload_hex", "payload_bytes",
+ }
+ for key in required_keys:
+ assert key in result, f"Missing key: {key}"
+
+ def test_trace_packet_path_info_type(self, handler):
+ # TRACE(9) + FLOOD(1): path_info should have type='trace'
+ # Provide minimal trace payload: tag(4) + auth(4) + flags(1) = 9 bytes
+ trace_payload = b"\x00" * 9
+ hex_str = _make_packet_hex(9, 1, payload_bytes=trace_payload)
+ result = handler.decode_meshcore_packet(hex_str)
+ assert result is not None
+ assert result["path_info"]["type"] == "trace"
+
+
+# ---------------------------------------------------------------------------
+# parse_advert
+# ---------------------------------------------------------------------------
+
+class TestParseAdvert:
+ """Tests for MessageHandler.parse_advert() — pure binary advert parsing."""
+
+ def test_too_short_payload_returns_empty_dict(self, handler):
+ # Must be >= 101 bytes
+ result = handler.parse_advert(b"\x00" * 100)
+ assert result == {}
+
+ def test_none_length_payload_short(self, handler):
+ result = handler.parse_advert(b"")
+ assert result == {}
+
+ def test_no_app_data_after_100_bytes_returns_empty(self, handler):
+ # Exactly 100 bytes means app_data is empty → returns {}
+ result = handler.parse_advert(b"\x00" * 100)
+ assert result == {}
+
+ def test_companion_advert_basic(self, handler):
+ # ADV_TYPE_CHAT=0x01, no optional fields
+ payload = _make_advert_payload(0x01)
+ result = handler.parse_advert(payload)
+ assert result is not None
+ assert result != {}
+ assert result["mode"] == "Companion"
+ assert "public_key" in result
+ assert len(result["public_key"]) == 64 # 32 bytes → 64 hex chars
+ assert "advert_time" in result
+ assert result["advert_time"] == 1700000000
+
+ def test_repeater_advert(self, handler):
+ payload = _make_advert_payload(0x02) # ADV_TYPE_REPEATER
+ result = handler.parse_advert(payload)
+ assert result["mode"] == "Repeater"
+
+ def test_room_server_advert(self, handler):
+ payload = _make_advert_payload(0x03) # ADV_TYPE_ROOM
+ result = handler.parse_advert(payload)
+ assert result["mode"] == "RoomServer"
+
+ def test_sensor_advert(self, handler):
+ payload = _make_advert_payload(0x04) # ADV_TYPE_SENSOR
+ result = handler.parse_advert(payload)
+ assert result["mode"] == "Sensor"
+
+ def test_unknown_type_advert(self, handler):
+ payload = _make_advert_payload(0x05) # No matching type
+ result = handler.parse_advert(payload)
+ assert result["mode"] == "Type5"
+
+ def test_companion_with_name(self, handler):
+ # ADV_NAME_MASK=0x80 | ADV_TYPE_CHAT=0x01
+ payload = _make_advert_payload(0x81, name="TestNode")
+ result = handler.parse_advert(payload)
+ assert result["mode"] == "Companion"
+ assert result["name"] == "TestNode"
+
+ def test_companion_with_location(self, handler):
+ # ADV_LATLON_MASK=0x10 | ADV_TYPE_CHAT=0x01
+ # lat=47.606209 → raw=47606209, lon=-122.332069 → raw=-122332069
+ lat_raw = 47606209
+ lon_raw = -122332069
+ payload = _make_advert_payload(
+ 0x11,
+ location_lat_raw=lat_raw,
+ location_lon_raw=lon_raw,
+ )
+ result = handler.parse_advert(payload)
+ assert result["mode"] == "Companion"
+ assert "lat" in result
+ assert "lon" in result
+ assert abs(result["lat"] - round(lat_raw / 1_000_000, 6)) < 1e-5
+ assert abs(result["lon"] - round(lon_raw / 1_000_000, 6)) < 1e-5
+
+ def test_advert_with_name_and_location(self, handler):
+ # ADV_LATLON_MASK=0x10 | ADV_NAME_MASK=0x80 | ADV_TYPE_CHAT=0x01 = 0x91
+ payload = _make_advert_payload(
+ 0x91,
+ location_lat_raw=10000000,
+ location_lon_raw=-20000000,
+ name="Rooftop",
+ )
+ result = handler.parse_advert(payload)
+ assert result["mode"] == "Companion"
+ assert "lat" in result and "lon" in result
+ assert result["name"] == "Rooftop"
+
+ def test_advert_with_feat1(self, handler):
+ # ADV_FEAT1_MASK=0x20 | ADV_TYPE_CHAT=0x01 = 0x21
+ payload = _make_advert_payload(0x21, feat1=0x1234)
+ result = handler.parse_advert(payload)
+ assert result["feat1"] == 0x1234
+
+ def test_advert_with_feat2(self, handler):
+ # ADV_FEAT2_MASK=0x40 | ADV_TYPE_CHAT=0x01 = 0x41
+ payload = _make_advert_payload(0x41, feat2=0xABCD)
+ result = handler.parse_advert(payload)
+ assert result["feat2"] == 0xABCD
+
+ def test_advert_with_all_optional_fields(self, handler):
+ # 0x10 | 0x20 | 0x40 | 0x80 | 0x02 = 0xF2 (REPEATER with all flags)
+ payload = _make_advert_payload(
+ 0xF2,
+ location_lat_raw=1000000,
+ location_lon_raw=-2000000,
+ feat1=0x0001,
+ feat2=0x0002,
+ name="AllFlagsRepeater",
+ )
+ result = handler.parse_advert(payload)
+ assert result["mode"] == "Repeater"
+ assert "lat" in result
+ assert "lon" in result
+ assert result["feat1"] == 0x0001
+ assert result["feat2"] == 0x0002
+ assert result["name"] == "AllFlagsRepeater"
+
+ def test_location_flag_but_too_short_returns_partial(self, handler):
+ # ADV_LATLON_MASK set but only 1 app_data byte (the flags) → too short for lat+lon
+ pub_key = b"\xaa" * 32
+ ts = (1700000000).to_bytes(4, "little")
+ sig = b"\xbb" * 64
+ flags = bytes([0x11]) # ADV_LATLON_MASK | ADV_TYPE_CHAT — no lat/lon data after
+ payload = pub_key + ts + sig + flags # 101 bytes, but no location data
+ result = handler.parse_advert(payload)
+ # Method returns advert dict without location (early return on short data)
+ assert "mode" in result
+ assert "lat" not in result
+
+ def test_public_key_in_result(self, handler):
+ pub_key = bytes(range(32)) # 0x00..0x1f
+ payload = _make_advert_payload(0x01, pub_key=pub_key)
+ result = handler.parse_advert(payload)
+ assert result["public_key"] == bytes(range(32)).hex()
+
+ def test_signature_in_result(self, handler):
+ sig = bytes([0xFF] * 64)
+ payload = _make_advert_payload(0x01, signature=sig)
+ result = handler.parse_advert(payload)
+ assert result["signature"] == "ff" * 64
+
+
+# ---------------------------------------------------------------------------
+# store_message_for_correlation and cleanup_old_messages
+# ---------------------------------------------------------------------------
+
+class TestMessageCorrelation:
+ """Tests for store_message_for_correlation(), cleanup_old_messages(), and
+ correlate_message_with_rf_data()."""
+
+ def test_store_message_adds_to_pending(self, handler):
+ handler.store_message_for_correlation("msg-001", {"pubkey_prefix": "aa"})
+ assert "msg-001" in handler.pending_messages
+ entry = handler.pending_messages["msg-001"]
+ assert entry["data"] == {"pubkey_prefix": "aa"}
+ assert entry["processed"] is False
+ assert isinstance(entry["timestamp"], float)
+
+ def test_store_message_overwrites_existing(self, handler):
+ handler.store_message_for_correlation("dup", {"v": 1})
+ handler.store_message_for_correlation("dup", {"v": 2})
+ assert handler.pending_messages["dup"]["data"]["v"] == 2
+
+ def test_cleanup_removes_expired_entries(self, handler):
+ handler.message_timeout = 5.0
+ # Store a message then backdate its timestamp well past the timeout
+ handler.store_message_for_correlation("old-msg", {"x": 1})
+ handler.pending_messages["old-msg"]["timestamp"] = time.time() - 100
+ handler.cleanup_old_messages()
+ assert "old-msg" not in handler.pending_messages
+
+ def test_cleanup_keeps_fresh_entries(self, handler):
+ handler.message_timeout = 60.0
+ handler.store_message_for_correlation("fresh", {"x": 2})
+ handler.cleanup_old_messages()
+ assert "fresh" in handler.pending_messages
+
+ def test_cleanup_empty_pending_is_safe(self, handler):
+ handler.pending_messages = {}
+ handler.cleanup_old_messages() # Should not raise
+
+ def test_correlate_unknown_message_id_returns_none(self, handler):
+ result = handler.correlate_message_with_rf_data("nonexistent-id")
+ assert result is None
+
+ def test_correlate_message_with_matching_rf_data(self, handler):
+ handler.store_message_for_correlation("m1", {"pubkey_prefix": "aabb"})
+ rf = {
+ "timestamp": time.time(),
+ "snr": 5,
+ "rssi": -80,
+ "packet_prefix": "aabb",
+ "pubkey_prefix": "aabb",
+ }
+ handler.recent_rf_data = [rf]
+ handler.rf_data_timeout = 60
+ result = handler.correlate_message_with_rf_data("m1")
+ assert result is not None
+ assert handler.pending_messages["m1"]["processed"] is True
+
+ def test_correlate_no_matching_rf_returns_none(self, handler):
+ handler.store_message_for_correlation("m2", {"pubkey_prefix": "ffff"})
+ handler.recent_rf_data = []
+ result = handler.correlate_message_with_rf_data("m2")
+ assert result is None
+
+
+# ---------------------------------------------------------------------------
+# try_correlate_pending_messages
+# ---------------------------------------------------------------------------
+
+class TestTryCorrelatePendingMessages:
+ """Tests for MessageHandler.try_correlate_pending_messages()."""
+
+ def test_marks_matching_message_processed(self, handler):
+ handler.store_message_for_correlation("pm1", {"pubkey_prefix": "ccdd"})
+ rf_data = {"pubkey_prefix": "ccdd", "packet_prefix": "ccdd", "timestamp": time.time()}
+ handler.try_correlate_pending_messages(rf_data)
+ assert handler.pending_messages["pm1"]["processed"] is True
+
+ def test_skips_already_processed_messages(self, handler):
+ handler.store_message_for_correlation("pm2", {"pubkey_prefix": "eeff"})
+ handler.pending_messages["pm2"]["processed"] = True
+ rf_data = {"pubkey_prefix": "eeff", "packet_prefix": "eeff", "timestamp": time.time()}
+ # Should not raise; processed flag remains True
+ handler.try_correlate_pending_messages(rf_data)
+ assert handler.pending_messages["pm2"]["processed"] is True
+
+ def test_no_match_does_not_mark_processed(self, handler):
+ handler.store_message_for_correlation("pm3", {"pubkey_prefix": "1111"})
+ rf_data = {"pubkey_prefix": "9999", "packet_prefix": "9999", "timestamp": time.time()}
+ handler.try_correlate_pending_messages(rf_data)
+ assert handler.pending_messages["pm3"]["processed"] is False
+
+ def test_partial_prefix_match_16chars(self, handler):
+ # If both pubkey_prefixes share first 16 chars, they correlate
+ long_key = "aabbccddeeff0011aabbccddeeff0011"
+ handler.store_message_for_correlation("pm4", {"pubkey_prefix": long_key})
+ rf_data = {"pubkey_prefix": long_key, "packet_prefix": long_key, "timestamp": time.time()}
+ handler.try_correlate_pending_messages(rf_data)
+ assert handler.pending_messages["pm4"]["processed"] is True
+
+
+# ---------------------------------------------------------------------------
+# handle_rf_log_data
+# ---------------------------------------------------------------------------
+
+class TestHandleRfLogData:
+ """Tests for MessageHandler.handle_rf_log_data() — async event handler."""
+
+ def _make_event(self, payload):
+ event = Mock()
+ event.payload = payload
+ return event
+
+ def _setup_handler(self, handler):
+ handler.logger = Mock()
+ handler.bot.transmission_tracker = None
+ handler.bot.web_viewer_integration = None
+
+ async def test_no_payload_attribute_logs_warning(self, handler):
+ self._setup_handler(handler)
+ event = Mock(spec=[]) # no .payload attribute
+ await handler.handle_rf_log_data(event)
+ handler.logger.warning.assert_called()
+
+ async def test_payload_none_logs_warning(self, handler):
+ self._setup_handler(handler)
+ event = Mock()
+ event.payload = None
+ await handler.handle_rf_log_data(event)
+ handler.logger.warning.assert_called()
+
+ async def test_payload_without_snr_field_no_store(self, handler):
+ self._setup_handler(handler)
+ event = self._make_event({"raw_hex": "1100de", "rssi": -80})
+ await handler.handle_rf_log_data(event)
+ # No SNR field → nothing stored in recent_rf_data
+ assert len(handler.recent_rf_data) == 0
+
+ async def test_snr_without_raw_hex_no_store(self, handler):
+ self._setup_handler(handler)
+ # Has snr but no raw_hex → packet_prefix is None → no store
+ event = self._make_event({"snr": 5.0})
+ await handler.handle_rf_log_data(event)
+ assert len(handler.recent_rf_data) == 0
+
+ async def test_snr_cached_from_packet_prefix(self, handler):
+ self._setup_handler(handler)
+ raw_hex = "a" * 64 # 32 hex chars → packet_prefix is first 32 chars = "a"*32
+ event = self._make_event({"snr": 7.5, "raw_hex": raw_hex})
+ await handler.handle_rf_log_data(event)
+ expected_prefix = raw_hex[:32]
+ assert handler.snr_cache.get(expected_prefix) == 7.5
+
+ async def test_rssi_cached_from_packet_prefix(self, handler):
+ self._setup_handler(handler)
+ raw_hex = "b" * 64
+ event = self._make_event({"snr": 3.0, "rssi": -95, "raw_hex": raw_hex})
+ await handler.handle_rf_log_data(event)
+ expected_prefix = raw_hex[:32]
+ assert handler.rssi_cache.get(expected_prefix) == -95
+
+ async def test_rf_data_stored_in_recent_rf_data(self, handler):
+ self._setup_handler(handler)
+ raw_hex = "c" * 64
+ event = self._make_event({"snr": 4.0, "raw_hex": raw_hex})
+ await handler.handle_rf_log_data(event)
+ assert len(handler.recent_rf_data) == 1
+ entry = handler.recent_rf_data[0]
+ assert entry["snr"] == 4.0
+ assert entry["packet_prefix"] == raw_hex[:32]
+
+ async def test_rf_data_added_to_timestamp_index(self, handler):
+ self._setup_handler(handler)
+ raw_hex = "d" * 64
+ event = self._make_event({"snr": 2.0, "raw_hex": raw_hex})
+ await handler.handle_rf_log_data(event)
+ assert len(handler.rf_data_by_timestamp) == 1
+
+ async def test_rf_data_added_to_pubkey_index(self, handler):
+ self._setup_handler(handler)
+ raw_hex = "e" * 64
+ event = self._make_event({"snr": 1.0, "raw_hex": raw_hex})
+ await handler.handle_rf_log_data(event)
+ prefix = raw_hex[:32]
+ assert prefix in handler.rf_data_by_pubkey
+
+ async def test_pubkey_from_metadata_stored(self, handler):
+ self._setup_handler(handler)
+ raw_hex = "f" * 64
+ meta = {"pubkey_prefix": "aabbccdd"}
+ event = self._make_event({"snr": 6.0, "raw_hex": raw_hex})
+ await handler.handle_rf_log_data(event, metadata=meta)
+ entry = handler.recent_rf_data[0]
+ assert entry["pubkey_prefix"] == "aabbccdd"
+
+ async def test_valid_packet_decoded_and_routing_stored(self, handler):
+ self._setup_handler(handler)
+ # Build a valid MeshCore packet (FLOOD + TXT_MSG, 0 hops)
+ pkt_hex = _make_packet_hex(2, 1, payload_bytes=b"\x48\x65\x6c\x6c\x6f")
+ # raw_hex must be >= 64 chars for packet_prefix, pad with zeros
+ padded = pkt_hex.ljust(64, "0")
+ event = self._make_event({"snr": 9.0, "raw_hex": padded})
+ await handler.handle_rf_log_data(event)
+ assert len(handler.recent_rf_data) == 1
+ # routing_info should be populated since raw_hex contains a valid packet
+ entry = handler.recent_rf_data[0]
+ assert entry["routing_info"] is not None
+
+ async def test_exception_does_not_propagate(self, handler):
+ self._setup_handler(handler)
+ event = Mock()
+ # Make deepcopy blow up
+ with patch("modules.message_handler.copy.deepcopy", side_effect=RuntimeError("deepcopy fail")):
+ await handler.handle_rf_log_data(event)
+ handler.logger.error.assert_called()
+
+
+# ---------------------------------------------------------------------------
+# _get_path_from_rf_data
+# ---------------------------------------------------------------------------
+
+class TestGetPathFromRfData:
+ """Tests for MessageHandler._get_path_from_rf_data() — path extraction helper."""
+
+ def test_routing_info_path_nodes_returned_directly(self, handler):
+ rf = {
+ "routing_info": {"path_nodes": ["ab", "cd"], "path_length": 2},
+ "raw_hex": "",
+ }
+ path_str, nodes, hops = handler._get_path_from_rf_data(rf)
+ assert path_str == "ab,cd"
+ assert nodes == ["ab", "cd"]
+ assert hops == 2
+
+ def test_no_raw_hex_returns_none_tuple(self, handler):
+ rf = {"routing_info": {}, "raw_hex": ""}
+ path_str, nodes, hops = handler._get_path_from_rf_data(rf)
+ assert path_str is None
+ assert nodes is None
+ assert hops == 255
+
+ def test_raw_hex_decoded_and_path_returned(self, handler):
+ path_b = bytes([0xAB, 0xCD])
+ pkt_hex = _make_packet_hex(4, 1, path_bytes=path_b, payload_bytes=b"\xEE",
+ hop_count=2, bytes_per_hop=1)
+ padded = pkt_hex.ljust(64, "0")
+ rf = {"routing_info": {}, "raw_hex": padded, "payload": ""}
+ path_str, nodes, hops = handler._get_path_from_rf_data(rf)
+ assert nodes is not None
+ assert len(nodes) == 2
+ assert hops == 2
+
+ def test_invalid_raw_hex_raises_or_returns_none_tuple(self, handler):
+ # Non-hex raw_hex triggers the same UnboundLocalError source bug as
+ # decode_meshcore_packet("ZZZZ") — document the actual behaviour.
+ rf = {"routing_info": {}, "raw_hex": "ZZZZ", "payload": ""}
+ try:
+ path_str, nodes, hops = handler._get_path_from_rf_data(rf)
+ assert path_str is None
+ assert nodes is None
+ except (ValueError, UnboundLocalError):
+ pass # expected — source-level bug causes exception to propagate
diff --git a/tests/test_moon_command.py b/tests/test_moon_command.py
new file mode 100644
index 0000000..5778030
--- /dev/null
+++ b/tests/test_moon_command.py
@@ -0,0 +1,210 @@
+"""Tests for modules.commands.moon_command — pure logic functions."""
+
+import configparser
+from unittest.mock import MagicMock, Mock
+
+import pytest
+
+from modules.commands.moon_command import MoonCommand
+from tests.conftest import mock_message
+
+
+def _make_bot():
+ bot = MagicMock()
+ bot.logger = Mock()
+ config = configparser.ConfigParser()
+ config.add_section("Bot")
+ config.set("Bot", "bot_name", "TestBot")
+ config.add_section("Channels")
+ config.set("Channels", "monitor_channels", "general")
+ config.set("Channels", "respond_to_dms", "true")
+ config.add_section("Keywords")
+ bot.config = config
+ bot.translator = MagicMock()
+ # Return first arg key as-is (mimics missing translation)
+ bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
+ bot.command_manager = MagicMock()
+ bot.command_manager.monitor_channels = ["general"]
+ return bot
+
+
+class TestTranslatePhaseName:
+ """Tests for _translate_phase_name."""
+
+ def setup_method(self):
+ self.cmd = MoonCommand(_make_bot())
+
+ def test_returns_original_when_no_translation(self):
+ # With our mock translator, translate returns the key, not a translation
+ # so _translate_phase_name should fall back to the original
+ result = self.cmd._translate_phase_name("Full Moon")
+ # Since translate returns the key (not found), fallback to original
+ assert result == "Full Moon" or "full_moon" in result or "Full Moon" in result
+
+ def test_strips_emoji_before_matching(self):
+ # Phase with emoji — should still match
+ result = self.cmd._translate_phase_name("🌕 Full Moon")
+ # Should not crash and should return something
+ assert result is not None
+ assert isinstance(result, str)
+
+ def test_unknown_phase_returned_as_is(self):
+ result = self.cmd._translate_phase_name("Totally Unknown Phase")
+ assert result == "Totally Unknown Phase"
+
+ def test_all_known_phases_dont_crash(self):
+ phases = [
+ "New Moon", "Waxing Crescent", "First Quarter", "Waxing Gibbous",
+ "Full Moon", "Waning Gibbous", "Last Quarter", "Waning Crescent"
+ ]
+ for phase in phases:
+ result = self.cmd._translate_phase_name(phase)
+ assert isinstance(result, str)
+
+
+class TestFormatMoonResponse:
+ """Tests for _format_moon_response."""
+
+ def setup_method(self):
+ self.cmd = MoonCommand(_make_bot())
+
+ def test_valid_moon_info_parsed(self):
+ moon_info = (
+ "MoonRise: Thu 04 06:47PM\n"
+ "Set: Fri 05 03:43AM\n"
+ "Phase: Full Moon @: 87%\n"
+ "FullMoon: Sun Sep 07 11:08AM\n"
+ "NewMoon: Sun Sep 21 12:54PM"
+ )
+ result = self.cmd._format_moon_response(moon_info)
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_partial_moon_info_falls_back(self):
+ # Only some keys present — should fall back to original or key
+ moon_info = "Phase: Full Moon\nSome: data"
+ result = self.cmd._format_moon_response(moon_info)
+ assert isinstance(result, str)
+
+ def test_empty_string_falls_back(self):
+ result = self.cmd._format_moon_response("")
+ assert isinstance(result, str)
+
+ def test_malformed_input_falls_back(self):
+ result = self.cmd._format_moon_response("This is not key:value format at all")
+ assert isinstance(result, str)
+
+ def test_moon_info_with_dates_format(self):
+ moon_info = (
+ "MoonRise: Thu 04 06:47PM\n"
+ "Set: Fri 05 03:43AM\n"
+ "Phase: Full Moon @: 87%\n"
+ "FullMoon: Sun Sep 07 11:08AM\n"
+ "NewMoon: Sun Sep 21 12:54PM"
+ )
+ result = self.cmd._format_moon_response(moon_info)
+ # Should have used 'format_with_dates' path since all keys present
+ # The output is a translated key so may contain the key name
+ assert isinstance(result, str)
+
+ def test_moon_info_without_full_new_moon(self):
+ moon_info = (
+ "MoonRise: Thu 04 06:47PM\n"
+ "Set: Fri 05 03:43AM\n"
+ "Phase: Waxing Gibbous @: 60%"
+ )
+ result = self.cmd._format_moon_response(moon_info)
+ assert isinstance(result, str)
+
+
+class TestMoonCommandEnabled:
+ """Tests for can_execute."""
+
+ def test_can_execute_enabled(self):
+ bot = _make_bot()
+ bot.config.add_section("Moon_Command")
+ bot.config.set("Moon_Command", "enabled", "true")
+ cmd = MoonCommand(bot)
+ msg = mock_message(content="moon", channel="general")
+ assert cmd.can_execute(msg) is True
+
+ def test_can_execute_disabled(self):
+ bot = _make_bot()
+ bot.config.add_section("Moon_Command")
+ bot.config.set("Moon_Command", "enabled", "false")
+ cmd = MoonCommand(bot)
+ msg = mock_message(content="moon", channel="general")
+ assert cmd.can_execute(msg) is False
+
+
+class TestGetHelpTextMoon:
+ def test_returns_description(self):
+ cmd = MoonCommand(_make_bot())
+ result = cmd.get_help_text()
+ assert isinstance(result, str)
+ assert result == cmd.description
+
+
+class TestFormatMoonPhaseNoAt:
+ """Test _format_moon_response when Phase has no @: (else branch)."""
+
+ def setup_method(self):
+ self.cmd = MoonCommand(_make_bot())
+
+ def test_phase_without_at_sign(self):
+ moon_info = (
+ "MoonRise: Thu 04 06:47PM\n"
+ "Set: Fri 05 03:43AM\n"
+ "Phase: Waxing Crescent"
+ )
+ result = self.cmd._format_moon_response(moon_info)
+ assert isinstance(result, str)
+
+ def test_exception_in_format_falls_back(self):
+ """If _format_moon_response raises, fallback is returned."""
+ # Force an exception by passing an object with no .split
+ result = self.cmd._format_moon_response(None)
+ assert isinstance(result, str)
+
+
+class TestTranslatePhaseNameFound:
+ """Test _translate_phase_name when a translation IS found (line 104)."""
+
+ def test_translation_returned_when_not_key(self):
+ """When translator returns actual text (not key), it's returned."""
+ bot = _make_bot()
+ # Override translator to return a real translation for the phase key
+ bot.translator.translate = Mock(
+ side_effect=lambda key, **kw: "Pleine Lune" if "full_moon" in key else key
+ )
+ cmd = MoonCommand(bot)
+ result = cmd._translate_phase_name("Full Moon")
+ assert result == "Pleine Lune"
+
+
+class TestMoonExecute:
+ """Tests for execute()."""
+
+ def test_execute_success(self):
+ from unittest.mock import AsyncMock, patch
+ import asyncio
+ bot = _make_bot()
+ cmd = MoonCommand(bot)
+ cmd.send_response = AsyncMock(return_value=True)
+ with patch("modules.commands.moon_command.get_moon", return_value="MoonRise: Thu 04 06:47PM\nSet: Fri 05 03:43AM\nPhase: Full Moon @: 87%"):
+ msg = mock_message(content="moon", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+ cmd.send_response.assert_called_once()
+
+ def test_execute_error_returns_false(self):
+ from unittest.mock import AsyncMock, patch
+ import asyncio
+ bot = _make_bot()
+ cmd = MoonCommand(bot)
+ cmd.send_response = AsyncMock(return_value=True)
+ with patch("modules.commands.moon_command.get_moon", side_effect=Exception("API error")):
+ msg = mock_message(content="moon", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is False
+ cmd.send_response.assert_called_once()
diff --git a/tests/test_multitest_command.py b/tests/test_multitest_command.py
new file mode 100644
index 0000000..dbf63ed
--- /dev/null
+++ b/tests/test_multitest_command.py
@@ -0,0 +1,156 @@
+"""Tests for modules.commands.multitest_command — pure logic functions."""
+
+import configparser
+from unittest.mock import MagicMock, Mock
+
+import pytest
+
+from modules.commands.multitest_command import MultitestCommand
+from tests.conftest import mock_message
+
+
+def _make_bot():
+ bot = MagicMock()
+ bot.logger = Mock()
+ config = configparser.ConfigParser()
+ config.add_section("Bot")
+ config.set("Bot", "bot_name", "TestBot")
+ config.add_section("Channels")
+ config.set("Channels", "monitor_channels", "general")
+ config.set("Channels", "respond_to_dms", "true")
+ config.add_section("Keywords")
+ config.add_section("Multitest_Command")
+ config.set("Multitest_Command", "enabled", "true")
+ bot.config = config
+ bot.translator = MagicMock()
+ bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
+ bot.command_manager = MagicMock()
+ bot.command_manager.monitor_channels = ["general"]
+ bot.prefix_hex_chars = 2
+ return bot
+
+
+class TestExtractPathFromRfData:
+ """Tests for extract_path_from_rf_data."""
+
+ def setup_method(self):
+ self.cmd = MultitestCommand(_make_bot())
+
+ def test_no_routing_info_returns_none(self):
+ result = self.cmd.extract_path_from_rf_data({})
+ assert result is None
+
+ def test_empty_routing_info_returns_none(self):
+ result = self.cmd.extract_path_from_rf_data({"routing_info": {}})
+ assert result is None
+
+ def test_path_nodes_extracted(self):
+ rf_data = {
+ "routing_info": {
+ "path_nodes": ["01", "7a", "55"]
+ }
+ }
+ result = self.cmd.extract_path_from_rf_data(rf_data)
+ assert result == "01,7a,55"
+
+ def test_path_hex_fallback(self):
+ rf_data = {
+ "routing_info": {
+ "path_nodes": [],
+ "path_hex": "017a55",
+ "bytes_per_hop": 1
+ }
+ }
+ result = self.cmd.extract_path_from_rf_data(rf_data)
+ assert result is not None
+ assert "01" in result
+
+ def test_invalid_nodes_skipped(self):
+ rf_data = {
+ "routing_info": {
+ "path_nodes": ["01", "zz", "55"]
+ }
+ }
+ result = self.cmd.extract_path_from_rf_data(rf_data)
+ # zz is invalid hex, should be excluded
+ if result:
+ assert "zz" not in result
+
+
+class TestExtractPathFromMessage:
+ """Tests for extract_path_from_message."""
+
+ def setup_method(self):
+ self.cmd = MultitestCommand(_make_bot())
+
+ def test_no_path_returns_none(self):
+ msg = mock_message(content="multitest", path=None)
+ msg.routing_info = None
+ result = self.cmd.extract_path_from_message(msg)
+ assert result is None
+
+ def test_direct_returns_none(self):
+ msg = mock_message(content="multitest", path="Direct")
+ msg.routing_info = None
+ result = self.cmd.extract_path_from_message(msg)
+ assert result is None
+
+ def test_zero_hops_returns_none(self):
+ msg = mock_message(content="multitest", path="0 hops")
+ msg.routing_info = None
+ result = self.cmd.extract_path_from_message(msg)
+ assert result is None
+
+ def test_comma_path_extracted(self):
+ msg = mock_message(content="multitest", path="01,7a,55")
+ msg.routing_info = None
+ result = self.cmd.extract_path_from_message(msg)
+ assert result is not None
+ assert "01" in result
+
+ def test_routing_info_path_preferred(self):
+ msg = mock_message(content="multitest", path="01,7a")
+ msg.routing_info = {
+ "path_length": 2,
+ "path_nodes": ["7a", "55"],
+ "bytes_per_hop": None
+ }
+ result = self.cmd.extract_path_from_message(msg)
+ # routing_info is preferred
+ assert result is not None
+
+
+class TestMatchesKeyword:
+ """Tests for matches_keyword."""
+
+ def setup_method(self):
+ self.cmd = MultitestCommand(_make_bot())
+
+ def test_multitest_matches(self):
+ assert self.cmd.matches_keyword(mock_message(content="multitest")) is True
+
+ def test_mt_matches(self):
+ assert self.cmd.matches_keyword(mock_message(content="mt")) is True
+
+ def test_exclamation_prefix(self):
+ assert self.cmd.matches_keyword(mock_message(content="!multitest")) is True
+
+ def test_other_does_not_match(self):
+ assert self.cmd.matches_keyword(mock_message(content="ping")) is False
+
+
+class TestCanExecute:
+ """Tests for can_execute."""
+
+ def test_enabled(self):
+ bot = _make_bot()
+ cmd = MultitestCommand(bot)
+ msg = mock_message(content="multitest", channel="general")
+ assert cmd.can_execute(msg) is True
+
+ def test_disabled(self):
+ bot = _make_bot()
+ bot.config.set("Multitest_Command", "enabled", "false")
+ cmd = MultitestCommand(bot)
+ msg = mock_message(content="multitest", channel="general")
+ assert cmd.can_execute(msg) is False
diff --git a/tests/test_rate_limiter.py b/tests/test_rate_limiter.py
index 6145f81..5c417fe 100644
--- a/tests/test_rate_limiter.py
+++ b/tests/test_rate_limiter.py
@@ -1,8 +1,9 @@
"""Tests for modules.rate_limiter."""
+import asyncio
import time
-from modules.rate_limiter import PerUserRateLimiter, RateLimiter
+from modules.rate_limiter import BotTxRateLimiter, ChannelRateLimiter, NominatimRateLimiter, PerUserRateLimiter, RateLimiter
class TestRateLimiter:
@@ -73,3 +74,324 @@ class TestPerUserRateLimiter:
limiter.record_send("user3")
assert "user1" not in limiter._last_send or len(limiter._last_send) <= 2
assert "user3" in limiter._last_send
+
+
+class TestRateLimiterStats:
+ """Tests for RateLimiter.get_stats."""
+
+ def test_initial_stats_all_zero(self):
+ limiter = RateLimiter(seconds=5)
+ stats = limiter.get_stats()
+ assert stats["total_sends"] == 0
+ assert stats["total_throttled"] == 0
+ assert stats["throttle_rate"] == 0.0
+
+ def test_stats_track_sends_and_throttle(self):
+ limiter = RateLimiter(seconds=60)
+ limiter.record_send()
+ limiter.can_send() # will be throttled
+ stats = limiter.get_stats()
+ assert stats["total_sends"] == 1
+ assert stats["total_throttled"] == 1
+ assert 0.0 < stats["throttle_rate"] <= 1.0
+
+ def test_time_until_next_when_fresh(self):
+ limiter = RateLimiter(seconds=5)
+ assert limiter.time_until_next() == 0
+
+
+class TestBotTxRateLimiter:
+ """Tests for BotTxRateLimiter."""
+
+ def test_can_tx_initially_true(self):
+ limiter = BotTxRateLimiter(seconds=5)
+ assert limiter.can_tx() is True
+
+ def test_can_tx_false_after_record(self):
+ limiter = BotTxRateLimiter(seconds=5)
+ limiter.record_tx()
+ assert limiter.can_tx() is False
+
+ def test_time_until_next_tx(self):
+ limiter = BotTxRateLimiter(seconds=5)
+ limiter.record_tx()
+ t = limiter.time_until_next_tx()
+ assert 0 < t <= 5
+
+ def test_get_stats_shape(self):
+ limiter = BotTxRateLimiter(seconds=5)
+ limiter.record_tx()
+ stats = limiter.get_stats()
+ assert "total_tx" in stats
+ assert "total_throttled" in stats
+ assert "throttle_rate" in stats
+ assert stats["total_tx"] == 1
+
+
+class TestChannelRateLimiter:
+ """Tests for ChannelRateLimiter."""
+
+ def test_unlisted_channel_always_allowed(self):
+ limiter = ChannelRateLimiter({"chan_a": 5.0})
+ assert limiter.can_send("chan_b") is True
+
+ def test_listed_channel_blocked_after_send(self):
+ limiter = ChannelRateLimiter({"chan_a": 60.0})
+ limiter.record_send("chan_a")
+ assert limiter.can_send("chan_a") is False
+
+ def test_record_send_unlisted_channel_no_error(self):
+ limiter = ChannelRateLimiter({})
+ limiter.record_send("nonexistent") # Should not raise
+
+ def test_time_until_next_unlisted_zero(self):
+ limiter = ChannelRateLimiter({"chan_a": 5.0})
+ assert limiter.time_until_next("unknown_chan") == 0.0
+
+ def test_time_until_next_listed_positive(self):
+ limiter = ChannelRateLimiter({"chan_a": 60.0})
+ limiter.record_send("chan_a")
+ t = limiter.time_until_next("chan_a")
+ assert 0 < t <= 60
+
+ def test_channels_returns_list(self):
+ limiter = ChannelRateLimiter({"chan_a": 5.0, "chan_b": 10.0})
+ channels = limiter.channels()
+ assert sorted(channels) == ["chan_a", "chan_b"]
+
+ def test_get_stats_shape(self):
+ limiter = ChannelRateLimiter({"chan_a": 5.0})
+ limiter.record_send("chan_a")
+ stats = limiter.get_stats()
+ assert "chan_a" in stats
+ assert "total_sends" in stats["chan_a"]
+
+ def test_zero_seconds_excluded(self):
+ # zero-second channels should be excluded (seconds <= 0)
+ limiter = ChannelRateLimiter({"chan_a": 0.0})
+ assert len(limiter.channels()) == 0
+
+
+class TestNominatimRateLimiter:
+ """Tests for NominatimRateLimiter."""
+
+ def test_can_request_initially_true(self):
+ limiter = NominatimRateLimiter(seconds=1.1)
+ assert limiter.can_request() is True
+
+ def test_can_request_false_after_record(self):
+ limiter = NominatimRateLimiter(seconds=5)
+ limiter.record_request()
+ assert limiter.can_request() is False
+
+ def test_time_until_next(self):
+ limiter = NominatimRateLimiter(seconds=5)
+ limiter.record_request()
+ t = limiter.time_until_next()
+ assert 0 < t <= 5
+
+ def test_get_stats_shape(self):
+ limiter = NominatimRateLimiter(seconds=5)
+ limiter.record_request()
+ stats = limiter.get_stats()
+ assert "total_requests" in stats
+ assert "total_throttled" in stats
+ assert stats["total_requests"] == 1
+
+
+class TestPerUserRateLimiterEvictEarlyReturn:
+ """Cover line 29: _evict_if_needed early return when key already present."""
+
+ def test_evict_skipped_when_key_already_exists(self):
+ # Fill the limiter to capacity with two entries.
+ limiter = PerUserRateLimiter(seconds=10, max_entries=2)
+ limiter.record_send("user1")
+ limiter.record_send("user2")
+ # Both slots are taken. Recording for user1 again must NOT evict anyone
+ # because the early-return on line 29 fires ("user1" is already in _last_send).
+ limiter.record_send("user1")
+ assert "user1" in limiter._last_send
+ assert "user2" in limiter._last_send
+ assert len(limiter._last_send) == 2
+
+ def test_order_deduplication_for_existing_key(self):
+ """Cover line 56: _order.remove(key) when key is already in _order."""
+ limiter = PerUserRateLimiter(seconds=10)
+ limiter.record_send("alice")
+ # "alice" is now in _order. A second record_send must remove and re-append her.
+ assert limiter._order.count("alice") == 1
+ limiter.record_send("alice")
+ # Still exactly one entry in _order for alice (no duplicates).
+ assert limiter._order.count("alice") == 1
+ # And she is now at the tail (most-recently used).
+ assert limiter._order[-1] == "alice"
+
+
+class TestBotTxRateLimiterWaitForTx:
+ """Cover lines 125-128: BotTxRateLimiter.wait_for_tx async wait loop."""
+
+ def test_wait_for_tx_when_already_ready(self):
+ """If can_tx() is True immediately, wait_for_tx returns without sleeping."""
+ limiter = BotTxRateLimiter(seconds=5)
+ # last_tx == 0 so can_tx() is True; the while loop never executes.
+ asyncio.run(limiter.wait_for_tx())
+
+ def test_wait_for_tx_after_backdate(self):
+ """Force can_tx() to be False initially, then backdate last_tx so it
+ becomes True on the very first sleep-free iteration check."""
+ limiter = BotTxRateLimiter(seconds=5)
+ limiter.record_tx()
+ # Backdate so the interval has elapsed — can_tx() returns True on
+ # the first evaluation, so the while body (lines 126-128) is entered
+ # at least once by making the initial state throttled and then
+ # immediately resolvable.
+ limiter.last_tx = time.time() - 10 # well past the 5-second window
+ # Now can_tx() is True so wait_for_tx returns immediately.
+ asyncio.run(limiter.wait_for_tx())
+
+ def test_wait_for_tx_loop_body_executed(self):
+ """Make can_tx() return False once then True, exercising the loop body."""
+ limiter = BotTxRateLimiter(seconds=60)
+ limiter.record_tx()
+ # Backdate last_tx just enough so can_tx() is True after we monkey-patch
+ # it to be False on the first call only, ensuring the loop body runs.
+ call_count = [0]
+ original_can_tx = limiter.can_tx
+
+ def patched_can_tx():
+ call_count[0] += 1
+ if call_count[0] == 1:
+ # First call: report not ready (exercises loop body).
+ # We also set last_tx far in the past so time_until_next_tx() == 0
+ # to avoid any real asyncio.sleep.
+ limiter.last_tx = time.time() - 200
+ return False
+ return original_can_tx()
+
+ limiter.can_tx = patched_can_tx
+ asyncio.run(limiter.wait_for_tx())
+ assert call_count[0] >= 2
+
+
+class TestNominatimRateLimiterGetLock:
+ """Cover lines 192-194: NominatimRateLimiter._get_lock lazy init."""
+
+ def test_get_lock_creates_lock_on_first_call(self):
+ limiter = NominatimRateLimiter(seconds=1.1)
+ assert limiter._lock is None
+
+ async def _inner():
+ lock = limiter._get_lock()
+ assert lock is not None
+ assert isinstance(lock, asyncio.Lock)
+ # Second call must return the same instance (lazy singleton).
+ lock2 = limiter._get_lock()
+ assert lock is lock2
+
+ asyncio.run(_inner())
+
+ def test_get_lock_returns_same_instance(self):
+ """_get_lock called twice returns identical object."""
+ async def _inner():
+ limiter = NominatimRateLimiter(seconds=1.1)
+ lock_a = limiter._get_lock()
+ lock_b = limiter._get_lock()
+ assert lock_a is lock_b
+
+ asyncio.run(_inner())
+
+
+class TestNominatimRateLimiterWaitForRequest:
+ """Cover lines 215-218: NominatimRateLimiter.wait_for_request async wait loop."""
+
+ def test_wait_for_request_when_already_ready(self):
+ """can_request() is True from the start; the loop never runs."""
+ limiter = NominatimRateLimiter(seconds=1.1)
+ asyncio.run(limiter.wait_for_request())
+
+ def test_wait_for_request_loop_body_executed(self):
+ """Make can_request() return False once then True, exercising the loop body."""
+ limiter = NominatimRateLimiter(seconds=60)
+ limiter.record_request()
+ call_count = [0]
+ original = limiter.can_request
+
+ def patched():
+ call_count[0] += 1
+ if call_count[0] == 1:
+ # Backdate so time_until_next() returns 0, avoiding a real sleep.
+ limiter.last_request = time.time() - 200
+ return False
+ return original()
+
+ limiter.can_request = patched
+ asyncio.run(limiter.wait_for_request())
+ assert call_count[0] >= 2
+
+
+class TestNominatimRateLimiterWaitAndRequest:
+ """Cover lines 222-228: NominatimRateLimiter.wait_and_request."""
+
+ def test_wait_and_request_when_ready(self):
+ """No sleep needed; last_request starts at 0."""
+ async def _inner():
+ limiter = NominatimRateLimiter(seconds=1.1)
+ before = time.time()
+ await limiter.wait_and_request()
+ assert limiter.last_request >= before
+ assert limiter._total_requests == 1
+
+ asyncio.run(_inner())
+
+ def test_wait_and_request_increments_total(self):
+ async def _inner():
+ limiter = NominatimRateLimiter(seconds=1.1)
+ await limiter.wait_and_request()
+ await limiter.wait_and_request()
+ assert limiter._total_requests == 2
+
+ asyncio.run(_inner())
+
+ def test_wait_and_request_sleeps_when_throttled(self):
+ """Backdate last_request by much less than seconds so the sleep branch runs,
+ but use a tiny seconds value so the actual sleep is negligible."""
+ async def _inner():
+ limiter = NominatimRateLimiter(seconds=0.05)
+ # Record a request right now so time_since_last < seconds.
+ limiter.record_request()
+ before = time.time()
+ await limiter.wait_and_request()
+ # Two requests recorded total (one manual, one via wait_and_request).
+ assert limiter._total_requests == 2
+ # At least some time passed (the sleep).
+ assert time.time() - before >= 0.0 # non-negative; sleep was brief
+
+ asyncio.run(_inner())
+
+
+class TestNominatimRateLimiterWaitForRequestSync:
+ """Cover lines 232-235: NominatimRateLimiter.wait_for_request_sync."""
+
+ def test_wait_for_request_sync_when_ready(self):
+ """can_request() is True immediately; the while loop body never executes."""
+ limiter = NominatimRateLimiter(seconds=1.1)
+ limiter.wait_for_request_sync() # Should return immediately without sleeping
+
+ def test_wait_for_request_sync_loop_body_executed(self):
+ """Make can_request() return False once then True so the loop body runs."""
+ limiter = NominatimRateLimiter(seconds=60)
+ limiter.record_request()
+ call_count = [0]
+ original = limiter.can_request
+
+ def patched():
+ call_count[0] += 1
+ if call_count[0] == 1:
+ # Backdate so time_until_next() returns 0, avoiding an actual sleep.
+ limiter.last_request = time.time() - 200
+ return False
+ return original()
+
+ limiter.can_request = patched
+ limiter.wait_for_request_sync()
+ assert call_count[0] >= 2
diff --git a/tests/test_repeater_manager.py b/tests/test_repeater_manager.py
index 5fec630..e07b718 100644
--- a/tests/test_repeater_manager.py
+++ b/tests/test_repeater_manager.py
@@ -1,7 +1,8 @@
"""Tests for RepeaterManager pure logic (no network, no geocoding)."""
import configparser
-from unittest.mock import AsyncMock, Mock, patch
+from datetime import datetime, timedelta
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
@@ -486,3 +487,746 @@ class TestCheckAndAutoPurge:
rm.bot.meshcore = Mock(side_effect=Exception("fail"))
result = await rm.check_and_auto_purge()
assert result is False
+
+ async def test_companion_purge_triggered_when_repeater_purge_insufficient(self, rm):
+ """When repeater purge doesn't bring count below threshold, companion purge fires."""
+ rm.auto_purge_enabled = True
+ rm.auto_purge_threshold = 10
+ rm.companion_purge_enabled = True
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {str(i): {} for i in range(15)}
+
+ async def fake_repeater_purge(count):
+ # Simulate no contacts removed (still above threshold after)
+ return True
+
+ with patch.object(rm, "_auto_purge_repeaters", side_effect=fake_repeater_purge), \
+ patch.object(rm, "_auto_purge_companions", new_callable=AsyncMock, return_value=True) as mock_comp:
+ result = await rm.check_and_auto_purge()
+
+ mock_comp.assert_called_once()
+ assert result is True
+
+ async def test_returns_false_when_purge_fails(self, rm):
+ """When both purge counts succeed=False, check_and_auto_purge returns False."""
+ rm.auto_purge_enabled = True
+ rm.auto_purge_threshold = 10
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {str(i): {} for i in range(15)}
+
+ with patch.object(rm, "_auto_purge_repeaters", new_callable=AsyncMock, return_value=False):
+ result = await rm.check_and_auto_purge()
+
+ assert result is False
+
+
+# ---------------------------------------------------------------------------
+# _determine_device_type — gap branches (lines 593-599, 626-642)
+# ---------------------------------------------------------------------------
+
+class TestDetermineDeviceTypeGaps:
+ """Cover the previously-uncovered branches of _determine_device_type."""
+
+ def test_advert_mode_companion(self, rm):
+ """mode='Companion' in advert_data should return 'Companion'."""
+ result = rm._determine_device_type(0, "Bob", advert_data={"mode": "Companion"})
+ assert result == "Companion"
+
+ def test_advert_mode_sensor(self, rm):
+ """mode='Sensor' in advert_data should return 'Sensor'."""
+ result = rm._determine_device_type(0, "WeatherNode", advert_data={"mode": "Sensor"})
+ assert result == "Sensor"
+
+ def test_advert_mode_unknown_passthrough(self, rm):
+ """An unrecognised mode string is returned verbatim (str(mode))."""
+ result = rm._determine_device_type(0, "Gadget", advert_data={"mode": "CustomWidget"})
+ assert result == "CustomWidget"
+
+ def test_name_based_bot_detection(self, rm):
+ """Fallback name-based detection: 'automated' → Bot."""
+ result = rm._determine_device_type(0, "AutomatedHelper")
+ assert result == "Bot"
+
+ def test_name_based_gateway_bridge(self, rm):
+ """Fallback name-based detection: 'bridge' in name → Gateway."""
+ result = rm._determine_device_type(0, "MQTT Bridge Node")
+ assert result == "Gateway"
+
+ def test_name_based_sensor(self, rm):
+ """Fallback name-based detection: 'sens' in name → Sensor."""
+ result = rm._determine_device_type(0, "Temp-Sens-01")
+ assert result == "Sensor"
+
+ def test_name_based_gateway_gw(self, rm):
+ """Fallback name-based detection: 'gw' in name → Gateway."""
+ result = rm._determine_device_type(0, "My-GW-Node")
+ assert result == "Gateway"
+
+ def test_device_type_zero_unknown_name_defaults_companion(self, rm):
+ """device_type=0 with an ordinary name falls through to Companion."""
+ result = rm._determine_device_type(0, "Charlie Brown")
+ assert result == "Companion"
+
+
+# ---------------------------------------------------------------------------
+# _update_currently_tracked_status (async, line 742)
+# ---------------------------------------------------------------------------
+
+class TestUpdateCurrentlyTrackedStatus:
+ """Tests for _update_currently_tracked_status (covers the meshcore contacts loop)."""
+
+ async def test_tracked_when_public_key_matches(self, rm):
+ """Contact in meshcore.contacts with matching public_key → is_tracked=True."""
+ target_key = "aabbccdd"
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {
+ "slot1": {"public_key": target_key},
+ }
+ rm.db_manager.execute_update = Mock()
+
+ await rm._update_currently_tracked_status(target_key)
+
+ rm.db_manager.execute_update.assert_called_once()
+ args = rm.db_manager.execute_update.call_args[0]
+ # Second positional arg is the tuple (is_tracked, public_key)
+ assert args[1] == (True, target_key)
+
+ async def test_not_tracked_when_key_absent(self, rm):
+ """Contact not found in meshcore.contacts → is_tracked=False."""
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {
+ "slot1": {"public_key": "11223344"},
+ }
+ rm.db_manager.execute_update = Mock()
+
+ await rm._update_currently_tracked_status("aabbccdd")
+
+ args = rm.db_manager.execute_update.call_args[0]
+ assert args[1] == (False, "aabbccdd")
+
+ async def test_contact_key_used_when_no_public_key_field(self, rm):
+ """When contact_data has no 'public_key' field, the dict key itself is compared."""
+ target_key = "aabbccdd"
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {target_key: {}} # no 'public_key' field
+ rm.db_manager.execute_update = Mock()
+
+ await rm._update_currently_tracked_status(target_key)
+
+ args = rm.db_manager.execute_update.call_args[0]
+ assert args[1] == (True, target_key)
+
+ async def test_meshcore_has_no_contacts_attr(self, rm):
+ """When meshcore object has no contacts attribute → is_tracked=False, no exception."""
+ rm.bot.meshcore = object() # plain object, no 'contacts' attr
+ rm.db_manager.execute_update = Mock()
+
+ await rm._update_currently_tracked_status("aabbccdd")
+
+ args = rm.db_manager.execute_update.call_args[0]
+ assert args[1] == (False, "aabbccdd")
+
+ async def test_db_exception_is_caught(self, rm):
+ """DB error in execute_update should be swallowed and logged, not raised."""
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {}
+ rm.db_manager.execute_update = Mock(side_effect=Exception("db error"))
+
+ # Should not raise
+ await rm._update_currently_tracked_status("aabbccdd")
+ rm.logger.error.assert_called()
+
+
+# ---------------------------------------------------------------------------
+# track_contact_advertisement (async, lines 311-445)
+# ---------------------------------------------------------------------------
+
+class TestTrackContactAdvertisement:
+ """Tests for track_contact_advertisement — the main contact upsert path."""
+
+ def _make_advert(self, public_key="aabb1122", name="TestNode", **kwargs):
+ data = {"public_key": public_key, "name": name, "type": 1}
+ data.update(kwargs)
+ return data
+
+ async def test_missing_public_key_returns_false(self, rm):
+ """Advertisement without public_key should return False immediately."""
+ result = await rm.track_contact_advertisement({"name": "Nameless"})
+ assert result is False
+ rm.logger.warning.assert_called()
+
+ async def test_empty_public_key_returns_false(self, rm):
+ result = await rm.track_contact_advertisement({"public_key": "", "name": "X"})
+ assert result is False
+
+ async def test_new_contact_inserted_returns_true(self, rm):
+ """New contact (not in DB) is inserted and True is returned."""
+ advert = self._make_advert()
+
+ # DB query for duplicate packet → nothing
+ # DB query for existing contact → nothing
+ # execute_update for INSERT, execute_update for is_currently_tracked
+ rm.db_manager.execute_query = Mock(return_value=[])
+ rm.db_manager.execute_update = Mock()
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {}
+
+ with patch.object(rm, "_track_daily_advertisement", new_callable=AsyncMock):
+ result = await rm.track_contact_advertisement(advert)
+
+ assert result is True
+ # execute_update called at least once (INSERT)
+ rm.db_manager.execute_update.assert_called()
+
+ async def test_existing_contact_updated_returns_true(self, rm):
+ """Existing contact in DB is updated (advert_count incremented) → True."""
+ advert = self._make_advert()
+ existing_row = {
+ "id": 1,
+ "advert_count": 5,
+ "last_heard": "2024-01-01 00:00:00",
+ "latitude": None,
+ "longitude": None,
+ "city": None,
+ "state": None,
+ "country": None,
+ "out_path": None,
+ "out_path_len": -1,
+ "out_bytes_per_hop": None,
+ }
+ rm.db_manager.execute_query = Mock(return_value=[existing_row])
+ rm.db_manager.execute_update = Mock()
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {}
+
+ with patch.object(rm, "_track_daily_advertisement", new_callable=AsyncMock):
+ result = await rm.track_contact_advertisement(advert)
+
+ assert result is True
+ # The UPDATE path passes advert_count=6
+ update_call = rm.db_manager.execute_update.call_args_list[0]
+ assert "UPDATE" in update_call[0][0]
+ params = update_call[0][1]
+ # advert_count is third positional param in the UPDATE tuple
+ assert params[2] == 6
+
+ async def test_duplicate_packet_hash_skips_and_returns_true(self, rm):
+ """When packet_hash is already in unique_advert_packets, return True without re-inserting."""
+ advert = self._make_advert()
+ packet_hash = "deadbeef12345678"
+
+ def fake_query(query, params=None):
+ if "unique_advert_packets" in query and "public_key" in query and "packet_hash" in query:
+ return [{"id": 99}] # Simulate duplicate found
+ return []
+
+ rm.db_manager.execute_query = Mock(side_effect=fake_query)
+ rm.db_manager.execute_update = Mock()
+
+ result = await rm.track_contact_advertisement(advert, packet_hash=packet_hash)
+
+ assert result is True
+ # execute_update should NOT have been called (no upsert)
+ rm.db_manager.execute_update.assert_not_called()
+
+ async def test_signal_info_direct_hop_saves_rssi(self, rm):
+ """Zero-hop signal_info should populate signal_strength and snr in the INSERT."""
+ advert = self._make_advert()
+ signal_info = {"hops": 0, "rssi": -85.0, "snr": 7.5}
+
+ rm.db_manager.execute_query = Mock(return_value=[])
+ rm.db_manager.execute_update = Mock()
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {}
+
+ with patch.object(rm, "_track_daily_advertisement", new_callable=AsyncMock):
+ result = await rm.track_contact_advertisement(advert, signal_info=signal_info)
+
+ assert result is True
+ insert_call = rm.db_manager.execute_update.call_args_list[0]
+ params = insert_call[0][1]
+ # signal_strength and snr should be -85.0 and 7.5 in the INSERT params
+ assert -85.0 in params
+ assert 7.5 in params
+
+ async def test_multi_hop_signal_info_not_saved(self, rm):
+ """Multi-hop (hops>0) signal_info should NOT persist RSSI/SNR."""
+ advert = self._make_advert()
+ signal_info = {"hops": 2, "rssi": -70.0, "snr": 9.0}
+
+ rm.db_manager.execute_query = Mock(return_value=[])
+ rm.db_manager.execute_update = Mock()
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {}
+
+ with patch.object(rm, "_track_daily_advertisement", new_callable=AsyncMock):
+ await rm.track_contact_advertisement(advert, signal_info=signal_info)
+
+ insert_call = rm.db_manager.execute_update.call_args_list[0]
+ params = insert_call[0][1]
+ # RSSI (-70.0) should NOT appear; signal_strength should be None
+ assert -70.0 not in params
+
+ async def test_db_exception_returns_false(self, rm):
+ """An unexpected exception during DB operations should return False."""
+ advert = self._make_advert()
+ rm.db_manager.execute_query = Mock(side_effect=Exception("db exploded"))
+
+ result = await rm.track_contact_advertisement(advert)
+
+ assert result is False
+ rm.logger.error.assert_called()
+
+ async def test_track_daily_advertisement_called(self, rm):
+ """_track_daily_advertisement should be awaited once on success."""
+ advert = self._make_advert()
+ rm.db_manager.execute_query = Mock(return_value=[])
+ rm.db_manager.execute_update = Mock()
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {}
+
+ with patch.object(rm, "_track_daily_advertisement", new_callable=AsyncMock) as mock_daily:
+ await rm.track_contact_advertisement(advert)
+
+ mock_daily.assert_awaited_once()
+
+ async def test_path_fields_preserved_from_existing(self, rm):
+ """When existing row already has out_path, the new advert should NOT overwrite it."""
+ advert = self._make_advert(out_path="new/path", out_path_len=2)
+ existing_row = {
+ "id": 1,
+ "advert_count": 3,
+ "last_heard": "2024-01-01 00:00:00",
+ "latitude": None,
+ "longitude": None,
+ "city": None,
+ "state": None,
+ "country": None,
+ "out_path": "original/path",
+ "out_path_len": 1,
+ "out_bytes_per_hop": None,
+ }
+ rm.db_manager.execute_query = Mock(return_value=[existing_row])
+ rm.db_manager.execute_update = Mock()
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {}
+
+ with patch.object(rm, "_track_daily_advertisement", new_callable=AsyncMock):
+ await rm.track_contact_advertisement(advert)
+
+ update_call = rm.db_manager.execute_update.call_args_list[0]
+ params = update_call[0][1]
+ assert "original/path" in params
+
+
+# ---------------------------------------------------------------------------
+# _track_daily_advertisement (async, lines 447-535)
+# ---------------------------------------------------------------------------
+
+class TestTrackDailyAdvertisement:
+ """Tests for _track_daily_advertisement — daily stats upsert logic."""
+
+ def _call(self, rm, public_key="aabb", name="Node", role="companion",
+ device_type="Companion", location_info=None, signal_strength=None,
+ snr=None, hop_count=None, timestamp=None, packet_hash=None):
+ if location_info is None:
+ location_info = {"latitude": None, "longitude": None,
+ "city": None, "state": None, "country": None}
+ if timestamp is None:
+ timestamp = datetime.now()
+ return rm._track_daily_advertisement(
+ public_key, name, role, device_type, location_info,
+ signal_strength, snr, hop_count, timestamp, packet_hash=packet_hash
+ )
+
+ async def test_new_daily_entry_inserted_for_unique_packet(self, rm):
+ """A new packet_hash on a new day → INSERT into daily_stats."""
+ rm.db_manager.execute_query = Mock(return_value=[])
+ rm.db_manager.execute_update = Mock()
+
+ await self._call(rm, packet_hash="newpacket1234567")
+
+ calls = [str(c) for c in rm.db_manager.execute_update.call_args_list]
+ assert any("daily_stats" in c for c in calls)
+
+ async def test_existing_daily_entry_updated(self, rm):
+ """When daily_stats already has a row for today, UPDATE is used."""
+ existing_daily = [{"id": 1, "advert_count": 4, "first_advert_time": "2024-01-01"}]
+ unique_count = [{"COUNT(*)": 5}]
+
+ call_count = {"n": 0}
+
+ def fake_query(query, params=None):
+ call_count["n"] += 1
+ if "unique_advert_packets" in query and "date" in query and "public_key" in query and "packet_hash" in query:
+ return [] # Packet not seen yet
+ elif "unique_advert_packets" in query and "COUNT(*)" in query:
+ return unique_count
+ elif "daily_stats" in query:
+ return existing_daily
+ return []
+
+ rm.db_manager.execute_query = Mock(side_effect=fake_query)
+ rm.db_manager.execute_update = Mock()
+
+ await self._call(rm, packet_hash="freshpacket12345")
+
+ calls = [str(c) for c in rm.db_manager.execute_update.call_args_list]
+ assert any("UPDATE daily_stats" in c for c in calls)
+
+ async def test_no_packet_hash_counts_as_unique(self, rm):
+ """When packet_hash=None, is_unique_packet=True → daily stat is written."""
+ rm.db_manager.execute_query = Mock(return_value=[])
+ rm.db_manager.execute_update = Mock()
+
+ await self._call(rm, packet_hash=None)
+
+ rm.db_manager.execute_update.assert_called()
+
+ async def test_default_zero_hash_counts_as_unique(self, rm):
+ """Packet hash '0000000000000000' is treated as no hash → unique."""
+ rm.db_manager.execute_query = Mock(return_value=[])
+ rm.db_manager.execute_update = Mock()
+
+ await self._call(rm, packet_hash="0000000000000000")
+
+ rm.db_manager.execute_update.assert_called()
+
+ async def test_duplicate_packet_hash_skips_count(self, rm):
+ """A packet_hash already seen today → no INSERT/UPDATE to daily_stats."""
+
+ def fake_query(query, params=None):
+ if "unique_advert_packets" in query and "packet_hash" in query:
+ return [{"id": 1}] # Already seen
+ return []
+
+ rm.db_manager.execute_query = Mock(side_effect=fake_query)
+ rm.db_manager.execute_update = Mock()
+
+ await self._call(rm, packet_hash="seenbeforepacket")
+
+ # daily_stats should not be touched because is_unique_packet=False
+ calls = [str(c) for c in rm.db_manager.execute_update.call_args_list]
+ assert not any("daily_stats" in c for c in calls)
+
+ async def test_exception_is_caught_and_logged(self, rm):
+ """An exception inside _track_daily_advertisement should be caught."""
+ rm.db_manager.execute_query = Mock(side_effect=Exception("boom"))
+
+ # Should not raise
+ await self._call(rm, packet_hash=None)
+ rm.logger.error.assert_called()
+
+
+# ---------------------------------------------------------------------------
+# _get_repeaters_for_purging (async, lines 902-991)
+# ---------------------------------------------------------------------------
+
+class TestGetRepeatersForPurging:
+ """Tests for _get_repeaters_for_purging — repeater selection logic."""
+
+ def _make_repeater_contact(self, public_key="rpt1", name="RPT-01", last_seen_days_ago=10,
+ type_val=2, lat=None, lon=None):
+ """Build a fake contact dict representing a repeater in meshcore.contacts."""
+ ts = (datetime.now() - timedelta(days=last_seen_days_ago)).isoformat()
+ contact = {
+ "public_key": public_key,
+ "adv_name": name,
+ "type": type_val,
+ "last_seen": ts,
+ }
+ if lat is not None:
+ contact["adv_lat"] = lat
+ contact["adv_lon"] = lon
+ return contact
+
+ async def test_returns_empty_when_no_contacts(self, rm):
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {}
+ result = await rm._get_repeaters_for_purging(5)
+ assert result == []
+
+ async def test_returns_empty_when_all_contacts_are_companions(self, rm):
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {
+ "c1": {"public_key": "c1", "type": 1, "name": "Alice"},
+ }
+ result = await rm._get_repeaters_for_purging(5)
+ assert result == []
+
+ async def test_returns_old_repeaters_up_to_count(self, rm):
+ """Old repeaters (>2 hours ago) should be returned up to count."""
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {
+ "r1": self._make_repeater_contact("rpt1", "RPT-01", last_seen_days_ago=10),
+ "r2": self._make_repeater_contact("rpt2", "RPT-02", last_seen_days_ago=5),
+ "r3": self._make_repeater_contact("rpt3", "RPT-03", last_seen_days_ago=1),
+ }
+ result = await rm._get_repeaters_for_purging(2)
+ assert len(result) == 2
+
+ async def test_recent_repeaters_excluded(self, rm):
+ """Repeaters seen within the last 2 hours should be excluded."""
+ rm.bot.meshcore = Mock()
+ # Only one very recent repeater
+ ts_now = datetime.now().isoformat()
+ rm.bot.meshcore.contacts = {
+ "r1": {"public_key": "rpt1", "adv_name": "RPT-01", "type": 2, "last_seen": ts_now},
+ }
+ result = await rm._get_repeaters_for_purging(5)
+ assert result == []
+
+ async def test_roomserver_type_detected(self, rm):
+ """type=3 should result in device_type='RoomServer' in the returned entry."""
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {
+ "rs1": self._make_repeater_contact("rs1", "RS-01", last_seen_days_ago=8, type_val=3),
+ }
+ result = await rm._get_repeaters_for_purging(5)
+ assert len(result) == 1
+ assert result[0]["device_type"] == "RoomServer"
+
+ async def test_oldest_sorted_first(self, rm):
+ """Oldest repeaters (7+ days) should appear before medium-old ones."""
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {
+ "r1": self._make_repeater_contact("rpt1", "Medium", last_seen_days_ago=4),
+ "r2": self._make_repeater_contact("rpt2", "VeryOld", last_seen_days_ago=10),
+ }
+ result = await rm._get_repeaters_for_purging(5)
+ # VeryOld (10 days) should come before Medium (4 days)
+ assert result[0]["name"] == "VeryOld"
+
+ async def test_integer_timestamp_parsed(self, rm):
+ """last_seen as a Unix epoch int should parse without exception."""
+ old_ts = int((datetime.now() - timedelta(days=5)).timestamp())
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {
+ "r1": {"public_key": "rpt1", "adv_name": "RPT-INT", "type": 2, "last_seen": old_ts},
+ }
+ result = await rm._get_repeaters_for_purging(5)
+ assert len(result) == 1
+
+ async def test_missing_last_seen_defaults_to_old(self, rm):
+ """Missing last_seen should be treated as 30 days ago (eligible for purge)."""
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {
+ "r1": {"public_key": "rpt1", "adv_name": "RPT-NOSEEN", "type": 2},
+ }
+ result = await rm._get_repeaters_for_purging(5)
+ assert len(result) == 1
+
+ async def test_exception_returns_empty_list(self, rm):
+ """An unexpected exception should return [] and log an error."""
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = Mock(side_effect=Exception("contacts error"))
+
+ result = await rm._get_repeaters_for_purging(5)
+
+ assert result == []
+ rm.logger.error.assert_called()
+
+
+# ---------------------------------------------------------------------------
+# _auto_purge_repeaters (async, lines 810-846)
+# ---------------------------------------------------------------------------
+
+class TestAutoPurgeRepeaters:
+ """Tests for _auto_purge_repeaters — orchestrates purge calls."""
+
+ async def test_returns_false_when_no_repeaters(self, rm):
+ with patch.object(rm, "_get_repeaters_for_purging", new_callable=AsyncMock, return_value=[]):
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {}
+ result = await rm._auto_purge_repeaters(3)
+ assert result is False
+ rm.logger.warning.assert_called()
+
+ async def test_purges_returned_repeaters(self, rm):
+ repeaters = [
+ {"public_key": "rpt1", "name": "RPT-01", "last_seen": "2024-01-01 00:00:00"},
+ {"public_key": "rpt2", "name": "RPT-02", "last_seen": "2024-01-02 00:00:00"},
+ ]
+ with patch.object(rm, "_get_repeaters_for_purging", new_callable=AsyncMock, return_value=repeaters), \
+ patch.object(rm, "purge_repeater_from_contacts", new_callable=AsyncMock, return_value=True):
+ result = await rm._auto_purge_repeaters(2)
+ assert result is True
+
+ async def test_returns_false_when_all_purges_fail(self, rm):
+ repeaters = [
+ {"public_key": "rpt1", "name": "RPT-01", "last_seen": "2024-01-01 00:00:00"},
+ ]
+ with patch.object(rm, "_get_repeaters_for_purging", new_callable=AsyncMock, return_value=repeaters), \
+ patch.object(rm, "purge_repeater_from_contacts", new_callable=AsyncMock, return_value=False):
+ result = await rm._auto_purge_repeaters(1)
+ assert result is False
+
+ async def test_exception_returns_false(self, rm):
+ with patch.object(rm, "_get_repeaters_for_purging", new_callable=AsyncMock,
+ side_effect=Exception("boom")):
+ result = await rm._auto_purge_repeaters(1)
+ assert result is False
+ rm.logger.error.assert_called()
+
+ async def test_partial_failure_still_returns_true(self, rm):
+ """If at least one repeater is purged successfully, return True."""
+ repeaters = [
+ {"public_key": "rpt1", "name": "RPT-01", "last_seen": "2024-01-01 00:00:00"},
+ {"public_key": "rpt2", "name": "RPT-02", "last_seen": "2024-01-01 00:00:00"},
+ ]
+ side_effects = [True, False] # first succeeds, second fails
+
+ with patch.object(rm, "_get_repeaters_for_purging", new_callable=AsyncMock, return_value=repeaters), \
+ patch.object(rm, "purge_repeater_from_contacts", new_callable=AsyncMock,
+ side_effect=side_effects):
+ result = await rm._auto_purge_repeaters(2)
+
+ assert result is True
+
+
+# ---------------------------------------------------------------------------
+# _get_companions_for_purging (async, lines 993-1162)
+# ---------------------------------------------------------------------------
+
+class TestGetCompanionsForPurging:
+ """Tests for _get_companions_for_purging — companion scoring and selection."""
+
+ def _make_companion(self, key="c1", name="Alice", last_seen_days_ago=60):
+ ts = (datetime.now() - timedelta(days=last_seen_days_ago)).isoformat()
+ return {
+ "public_key": key,
+ "adv_name": name,
+ "type": 1,
+ "last_seen": ts,
+ }
+
+ async def test_returns_empty_when_purge_disabled(self, rm):
+ rm.companion_purge_enabled = False
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {"c1": self._make_companion()}
+
+ result = await rm._get_companions_for_purging(5)
+
+ assert result == []
+
+ async def test_returns_empty_when_no_contacts(self, rm):
+ rm.companion_purge_enabled = True
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {}
+
+ result = await rm._get_companions_for_purging(5)
+
+ assert result == []
+
+ async def test_skips_acl_companions(self, rm):
+ """Companions in the ACL should not be returned for purging."""
+ rm.companion_purge_enabled = True
+ protected_key = "aclprotected1234"
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {"c1": self._make_companion(key=protected_key, name="Admin")}
+ rm.bot.config.add_section("Admin_ACL")
+ rm.bot.config.set("Admin_ACL", "admin_pubkeys", protected_key)
+ rm.db_manager.execute_query = Mock(return_value=[])
+
+ result = await rm._get_companions_for_purging(5)
+
+ assert result == []
+
+ async def test_skips_recently_active_companions(self, rm):
+ """Companions active within 2 hours should be excluded."""
+ rm.companion_purge_enabled = True
+ rm.bot.meshcore = Mock()
+ recent_ts = (datetime.now() - timedelta(minutes=30)).isoformat()
+ rm.bot.meshcore.contacts = {
+ "c1": {"public_key": "c1", "adv_name": "ActiveUser", "type": 1, "last_seen": recent_ts},
+ }
+
+ def fake_query(query, params=None):
+ if "complete_contact_tracking" in query:
+ # Return last_heard = recent (within 2 hours)
+ return [{"last_heard": recent_ts, "last_advert_timestamp": None,
+ "advert_count": 1, "first_heard": recent_ts}]
+ return []
+
+ rm.db_manager.execute_query = Mock(side_effect=fake_query)
+
+ with patch.object(rm, "_get_last_dm_activity",
+ return_value=datetime.now() - timedelta(minutes=30)), \
+ patch.object(rm, "_get_last_advert_activity", return_value=None):
+ result = await rm._get_companions_for_purging(5)
+
+ assert result == []
+
+ async def test_inactive_companion_included(self, rm):
+ """A companion with no recent activity and purge enabled should be included."""
+ rm.companion_purge_enabled = True
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {"c1": self._make_companion(last_seen_days_ago=90)}
+ rm.db_manager.execute_query = Mock(return_value=[])
+
+ with patch.object(rm, "_get_last_dm_activity", return_value=None), \
+ patch.object(rm, "_get_last_advert_activity", return_value=None):
+ result = await rm._get_companions_for_purging(5)
+
+ assert len(result) == 1
+ assert result[0]["public_key"] == "c1"
+
+ async def test_count_limits_results(self, rm):
+ """Result list should be capped at the requested count."""
+ rm.companion_purge_enabled = True
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {
+ f"c{i}": self._make_companion(key=f"c{i}", name=f"User{i}", last_seen_days_ago=90 + i)
+ for i in range(5)
+ }
+ rm.db_manager.execute_query = Mock(return_value=[])
+
+ with patch.object(rm, "_get_last_dm_activity", return_value=None), \
+ patch.object(rm, "_get_last_advert_activity", return_value=None):
+ result = await rm._get_companions_for_purging(2)
+
+ assert len(result) == 2
+
+ async def test_most_inactive_companion_first(self, rm):
+ """More inactive companions (higher days_inactive) should have lower purge_score → ranked first."""
+ rm.companion_purge_enabled = True
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {
+ "c1": self._make_companion("c1", "Somewhat-Old", last_seen_days_ago=30),
+ "c2": self._make_companion("c2", "Very-Old", last_seen_days_ago=200),
+ }
+ rm.db_manager.execute_query = Mock(return_value=[])
+
+ with patch.object(rm, "_get_last_dm_activity", return_value=None), \
+ patch.object(rm, "_get_last_advert_activity", return_value=None):
+ result = await rm._get_companions_for_purging(5)
+
+ assert len(result) == 2
+ assert result[0]["name"] == "Very-Old"
+
+ async def test_exception_returns_empty_list(self, rm):
+ rm.companion_purge_enabled = True
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = Mock(side_effect=Exception("boom"))
+
+ result = await rm._get_companions_for_purging(5)
+
+ assert result == []
+ rm.logger.error.assert_called()
+
+ async def test_purge_score_structure(self, rm):
+ """Returned companion dicts should contain expected fields."""
+ rm.companion_purge_enabled = True
+ rm.bot.meshcore = Mock()
+ rm.bot.meshcore.contacts = {"c1": self._make_companion(last_seen_days_ago=90)}
+ rm.db_manager.execute_query = Mock(return_value=[])
+
+ with patch.object(rm, "_get_last_dm_activity", return_value=None), \
+ patch.object(rm, "_get_last_advert_activity", return_value=None):
+ result = await rm._get_companions_for_purging(5)
+
+ assert len(result) == 1
+ companion = result[0]
+ for field in ("public_key", "name", "purge_score", "days_inactive",
+ "last_dm", "last_advert"):
+ assert field in companion, f"Missing field: {field}"
diff --git a/tests/test_scheduler_logic.py b/tests/test_scheduler_logic.py
index ebc64be..9d1150a 100644
--- a/tests/test_scheduler_logic.py
+++ b/tests/test_scheduler_logic.py
@@ -366,28 +366,66 @@ class TestMaybeRunDbBackup:
mock_run.assert_not_called()
def test_already_ran_today_does_not_run(self, scheduler):
- today = datetime.datetime.now().strftime("%Y-%m-%d")
- self._setup(scheduler, time_str="00:00", last_ran=f"{today}T00:01:00")
+ now = datetime.datetime.now()
+ today = now.strftime("%Y-%m-%d")
+ # Schedule 1 minute ago (inside window), but mark as already run today
+ sched_time = now - datetime.timedelta(minutes=1)
+ time_str = sched_time.strftime("%H:%M")
+ self._setup(scheduler, time_str=time_str, last_ran=f"{today}T00:01:00")
with patch.object(scheduler, "_run_db_backup") as mock_run:
scheduler._maybe_run_db_backup()
mock_run.assert_not_called()
- def test_runs_when_time_passed_and_not_run_today(self, scheduler):
- # Use yesterday as last_ran so today triggers a run
- yesterday = (datetime.datetime.now() - datetime.timedelta(days=1)).strftime("%Y-%m-%d")
- self._setup(scheduler, time_str="00:00", last_ran=f"{yesterday}T00:01:00")
+ def test_runs_within_fire_window(self, scheduler):
+ """Backup fires when now is within 2 minutes of the scheduled time."""
+ now = datetime.datetime.now()
+ # Set scheduled time to 1 minute ago so we're inside the 2-min window
+ sched_time = now - datetime.timedelta(minutes=1)
+ time_str = sched_time.strftime("%H:%M")
+ yesterday = (now - datetime.timedelta(days=1)).strftime("%Y-%m-%d")
+ self._setup(scheduler, time_str=time_str, last_ran=f"{yesterday}T00:01:00")
with patch.object(scheduler, "_run_db_backup") as mock_run:
scheduler._maybe_run_db_backup()
mock_run.assert_called_once()
+ def test_does_not_run_outside_fire_window(self, scheduler):
+ """Backup does NOT fire when the scheduled time passed more than 2 minutes ago."""
+ now = datetime.datetime.now()
+ # Set scheduled time to 5 minutes ago — outside the 2-min window
+ sched_time = now - datetime.timedelta(minutes=5)
+ time_str = sched_time.strftime("%H:%M")
+ yesterday = (now - datetime.timedelta(days=1)).strftime("%Y-%m-%d")
+ self._setup(scheduler, time_str=time_str, last_ran=f"{yesterday}T00:01:00")
+ with patch.object(scheduler, "_run_db_backup") as mock_run:
+ scheduler._maybe_run_db_backup()
+ mock_run.assert_not_called()
+
+ def test_does_not_run_before_scheduled_time(self, scheduler):
+ """Backup does NOT fire when the scheduled time is in the future."""
+ now = datetime.datetime.now()
+ sched_time = now + datetime.timedelta(minutes=30)
+ time_str = sched_time.strftime("%H:%M")
+ self._setup(scheduler, time_str=time_str, last_ran="")
+ with patch.object(scheduler, "_run_db_backup") as mock_run:
+ scheduler._maybe_run_db_backup()
+ mock_run.assert_not_called()
+
def test_weekly_on_wrong_day_does_not_run(self, scheduler):
- # Force a day that isn't Monday (weekday != 0) by using a Tuesday
- self._setup(scheduler, schedule="weekly", time_str="00:00", last_ran="")
+ # Use a time 1 min ago (inside the 2-min fire window) on a Tuesday
+ now = datetime.datetime.now()
+ sched_time = now - datetime.timedelta(minutes=1)
+ time_str = sched_time.strftime("%H:%M")
+ self._setup(scheduler, schedule="weekly", time_str=time_str, last_ran="")
fake_now = Mock()
- fake_now.weekday.return_value = 1 # Tuesday
- fake_now.replace.return_value = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
- fake_now.__lt__ = lambda s, o: False # now >= scheduled time
- fake_now.strftime = datetime.datetime.now().strftime
+ fake_now.weekday.return_value = 1 # Tuesday — not Monday
+ scheduled_dt = now.replace(
+ hour=sched_time.hour, minute=sched_time.minute, second=0, microsecond=0
+ )
+ fake_now.replace.return_value = scheduled_dt
+ fake_now.__gt__ = lambda s, o: False # inside window
+ fake_now.__lt__ = lambda s, o: False
+ fake_now.__sub__ = lambda s, o: now - o # for timedelta comparison
+ fake_now.strftime = now.strftime
fake_now.isocalendar.return_value = (2026, 11, 2)
with patch.object(scheduler, "get_current_time", return_value=fake_now):
with patch.object(scheduler, "_run_db_backup") as mock_run:
@@ -495,3 +533,751 @@ class TestAPSchedulerLifecycle:
assert str(field_map["hour"]) == "14"
assert str(field_map["minute"]) == "30"
scheduler.join(timeout=1)
+
+
+# ---------------------------------------------------------------------------
+# TASK-05 / BUG-024: last_db_backup_run updated after _maybe_run_db_backup
+# ---------------------------------------------------------------------------
+
+class TestDbBackupIntervalGuard:
+ """Verify last_db_backup_run is updated so the 300s guard works correctly."""
+
+ def test_last_db_backup_run_updated_after_call(self, scheduler):
+ """last_db_backup_run is set to ~now immediately after _maybe_run_db_backup."""
+ scheduler.last_db_backup_run = 0 # force guard to fire
+
+ with patch.object(scheduler, '_maybe_run_db_backup') as mock_backup:
+ before = time.time()
+ # Simulate the scheduler loop body: guard fires, backup runs, timestamp updated
+ if time.time() - scheduler.last_db_backup_run >= 300:
+ scheduler._maybe_run_db_backup()
+ scheduler.last_db_backup_run = time.time()
+ after = time.time()
+
+ mock_backup.assert_called_once()
+ assert scheduler.last_db_backup_run >= before
+ assert scheduler.last_db_backup_run <= after
+
+ def test_guard_prevents_second_call_within_300s(self, scheduler):
+ """After last_db_backup_run is updated, a second loop iteration does not call backup."""
+ scheduler.last_db_backup_run = 0
+
+ call_count = 0
+
+ def fake_backup():
+ nonlocal call_count
+ call_count += 1
+ scheduler.last_db_backup_run = time.time() # mirrors fixed scheduler code
+
+ with patch.object(scheduler, '_maybe_run_db_backup', side_effect=fake_backup):
+ # First iteration — guard fires
+ if time.time() - scheduler.last_db_backup_run >= 300:
+ scheduler._maybe_run_db_backup()
+ scheduler.last_db_backup_run = time.time()
+
+ # Second iteration immediately after — guard must NOT fire
+ if time.time() - scheduler.last_db_backup_run >= 300:
+ scheduler._maybe_run_db_backup()
+ scheduler.last_db_backup_run = time.time()
+
+ assert call_count == 1, "Backup should only run once; guard failed to prevent second call"
+
+ def test_initial_last_db_backup_run_is_zero(self, scheduler):
+ """last_db_backup_run starts at 0 so first check fires after 300s uptime."""
+ assert scheduler.last_db_backup_run == 0
+
+ def test_guard_fires_after_300s(self, scheduler):
+ """Guard fires when last_db_backup_run is more than 300s in the past."""
+ scheduler.last_db_backup_run = time.time() - 301
+ assert time.time() - scheduler.last_db_backup_run >= 300
+
+ def test_guard_does_not_fire_before_300s(self, scheduler):
+ """Guard does not fire when last run was less than 300s ago."""
+ scheduler.last_db_backup_run = time.time() - 10
+ assert not (time.time() - scheduler.last_db_backup_run >= 300)
+
+ def test_restart_seeds_last_ran_from_db(self, scheduler):
+ """On first _maybe_run_db_backup call after restart, ran_at is loaded from DB metadata."""
+ now = datetime.datetime.now()
+ today = now.strftime("%Y-%m-%d")
+ # DB says backup ran today
+ scheduler.bot.db_manager.get_metadata.return_value = f"{today}T01:00:00"
+ scheduler._last_db_backup_stats = {}
+
+ # Schedule 1 min ago (inside 2-min window) to ensure we'd run if not for dedup
+ sched_time = now - datetime.timedelta(minutes=1)
+ time_str = sched_time.strftime("%H:%M")
+
+ def maint(key):
+ return {
+ "db_backup_enabled": "true",
+ "db_backup_schedule": "daily",
+ "db_backup_time": time_str,
+ "db_backup_retention_count": "7",
+ "db_backup_dir": "/tmp",
+ }.get(key, "")
+ scheduler._get_maint = Mock(side_effect=maint)
+
+ with patch.object(scheduler, "_run_db_backup") as mock_run:
+ scheduler._maybe_run_db_backup()
+ # Should NOT run because DB says it already ran today
+ mock_run.assert_not_called()
+ # And ran_at should be seeded from DB
+ assert scheduler._last_db_backup_stats.get("ran_at", "").startswith(today)
+
+
+# ---------------------------------------------------------------------------
+# _format_email_body — pure logic, no external calls
+# ---------------------------------------------------------------------------
+
+
+class TestFormatEmailBody:
+ """Tests for _format_email_body — pure string builder."""
+
+ def setup_method(self):
+ bot = Mock()
+ bot.logger = Mock()
+ bot.config = ConfigParser()
+ bot.config.add_section("Bot")
+ bot.connected = True
+ self.scheduler = MessageScheduler(bot)
+
+ def _basic_stats(self):
+ return {
+ "uptime": "2d 3h",
+ "contacts_24h": 5,
+ "contacts_new_24h": 1,
+ "contacts_total": 42,
+ "db_size_mb": "12.3",
+ "errors_24h": 0,
+ "criticals_24h": 0,
+ }
+
+ def test_returns_string(self):
+ result = self.scheduler._format_email_body(self._basic_stats(), "2026-01-01 00:00", "2026-01-02 00:00")
+ assert isinstance(result, str)
+
+ def test_contains_period(self):
+ result = self.scheduler._format_email_body(self._basic_stats(), "start", "end")
+ assert "start" in result
+ assert "end" in result
+
+ def test_contains_uptime(self):
+ result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
+ assert "2d 3h" in result
+
+ def test_contains_db_section(self):
+ result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
+ assert "DATABASE" in result
+ assert "12.3" in result
+
+ def test_contains_error_section(self):
+ result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
+ assert "ERRORS" in result
+
+ def test_bot_connected_yes(self):
+ self.scheduler.bot.connected = True
+ result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
+ assert "yes" in result
+
+ def test_bot_connected_no(self):
+ self.scheduler.bot.connected = False
+ result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
+ assert "no" in result
+
+ def test_retention_ran_at_included(self):
+ self.scheduler._last_retention_stats = {"ran_at": "2026-01-01T03:00:00"}
+ result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
+ assert "2026-01-01T03:00:00" in result
+
+ def test_retention_error_included(self):
+ self.scheduler._last_retention_stats = {"error": "DB locked"}
+ result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
+ assert "DB locked" in result
+
+ def test_log_file_section_included(self):
+ stats = self._basic_stats()
+ stats["log_file"] = "/var/log/bot.log"
+ stats["log_size_mb"] = "5.0"
+ result = self.scheduler._format_email_body(stats, "s", "e")
+ assert "/var/log/bot.log" in result
+ assert "5.0" in result
+
+ def test_log_rotated_yes(self):
+ stats = self._basic_stats()
+ stats["log_file"] = "/var/log/bot.log"
+ stats["log_size_mb"] = "5.0"
+ stats["log_rotated_24h"] = True
+ stats["log_backup_size_mb"] = "4.9"
+ result = self.scheduler._format_email_body(stats, "s", "e")
+ assert "yes" in result
+ assert "4.9" in result
+
+ def test_log_rotated_no(self):
+ stats = self._basic_stats()
+ stats["log_file"] = "/var/log/bot.log"
+ stats["log_size_mb"] = "5.0"
+ stats["log_rotated_24h"] = False
+ result = self.scheduler._format_email_body(stats, "s", "e")
+ assert "Rotated : no" in result
+
+ def test_missing_optional_stats_use_nap(self):
+ result = self.scheduler._format_email_body({}, "s", "e")
+ assert "n/a" in result or "unknown" in result
+
+ def test_no_log_file_no_log_section(self):
+ stats = self._basic_stats()
+ result = self.scheduler._format_email_body(stats, "s", "e")
+ assert "LOG FILES" not in result
+
+ def test_ends_with_config_hint(self):
+ result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
+ assert "/config" in result
+
+
+# ---------------------------------------------------------------------------
+# _send_nightly_email disabled path (no smtplib)
+# ---------------------------------------------------------------------------
+
+
+class TestSendNightlyEmailDisabled:
+ def test_disabled_returns_immediately(self):
+ bot = Mock()
+ bot.logger = Mock()
+ bot.config = ConfigParser()
+ bot.config.add_section("Bot")
+ scheduler = MessageScheduler(bot)
+
+ def _get_notif(key):
+ return {"nightly_enabled": "false"}.get(key, "")
+
+ scheduler._get_notif = Mock(side_effect=_get_notif)
+ # Should not raise and should not call smtplib
+ scheduler._send_nightly_email()
+ # No assertion needed — if it reaches here without smtplib, it returned early
+
+
+# ---------------------------------------------------------------------------
+# Helper — shared bot + scheduler factory used by several new test classes
+# ---------------------------------------------------------------------------
+
+import asyncio
+import configparser as _configparser
+from unittest.mock import AsyncMock, MagicMock
+
+
+def _make_scheduler():
+ """Return a MessageScheduler with a fully-mocked bot, skipping setup_scheduled_messages."""
+ bot = MagicMock()
+ bot.connected = True
+ bot.logger = Mock()
+ config = _configparser.ConfigParser()
+ config.add_section("Bot")
+ config.set("Bot", "advert_interval_hours", "0")
+ bot.config = config
+ bot.main_event_loop = None
+
+ # db_manager.connection() context manager
+ conn_mock = MagicMock()
+ conn_mock.__enter__ = Mock(return_value=conn_mock)
+ conn_mock.__exit__ = Mock(return_value=False)
+ cursor_mock = MagicMock()
+ cursor_mock.fetchone.return_value = None
+ cursor_mock.fetchall.return_value = []
+ conn_mock.cursor.return_value = cursor_mock
+ bot.db_manager.connection.return_value = conn_mock
+
+ with patch.object(MessageScheduler, "setup_scheduled_messages"):
+ scheduler = MessageScheduler(bot)
+ return scheduler
+
+
+# ---------------------------------------------------------------------------
+# TestGetMeshInfo
+# ---------------------------------------------------------------------------
+
+
+class TestGetMeshInfo:
+ """Tests for _get_mesh_info() async method (lines 152–293)."""
+
+ def test_returns_dict_with_required_keys(self):
+ scheduler = _make_scheduler()
+ # Remove repeater_manager so we fall to the fallback path
+ del scheduler.bot.repeater_manager
+ result = asyncio.run(scheduler._get_mesh_info())
+ required = [
+ "total_contacts",
+ "total_repeaters",
+ "total_companions",
+ "total_roomservers",
+ "total_sensors",
+ "recent_activity_24h",
+ "new_companions_7d",
+ "new_repeaters_7d",
+ ]
+ for key in required:
+ assert key in result
+
+ def test_uses_repeater_manager_stats_when_available(self):
+ scheduler = _make_scheduler()
+ stats_payload = {
+ "total_heard": 15,
+ "by_role": {
+ "repeater": 3,
+ "companion": 10,
+ "roomserver": 1,
+ "sensor": 1,
+ },
+ "recent_activity": 7,
+ }
+ scheduler.bot.repeater_manager.get_contact_statistics = AsyncMock(
+ return_value=stats_payload
+ )
+ result = asyncio.run(scheduler._get_mesh_info())
+ assert result["total_contacts"] == 15
+ assert result["total_repeaters"] == 3
+ assert result["total_companions"] == 10
+ assert result["recent_activity_24h"] == 7
+
+ def test_fallback_to_meshcore_contacts_when_repeater_manager_absent(self):
+ scheduler = _make_scheduler()
+ del scheduler.bot.repeater_manager
+ scheduler.bot.meshcore.contacts = {"a": {}, "b": {}, "c": {}}
+ result = asyncio.run(scheduler._get_mesh_info())
+ assert result["total_contacts"] == 3
+
+ def test_fallback_counts_repeaters_and_companions_when_repeater_manager_present(self):
+ """When repeater_manager returns 0 total_heard, falls back to meshcore.contacts
+ and uses repeater_manager._is_repeater_device to classify."""
+ scheduler = _make_scheduler()
+ scheduler.bot.repeater_manager.get_contact_statistics = AsyncMock(
+ return_value={"total_heard": 0, "by_role": {}, "recent_activity": 0}
+ )
+ scheduler.bot.meshcore.contacts = {
+ "key1": {"type": "repeater"},
+ "key2": {"type": "companion"},
+ "key3": {"type": "companion"},
+ }
+
+ def _is_repeater(contact_data):
+ return contact_data.get("type") == "repeater"
+
+ scheduler.bot.repeater_manager._is_repeater_device = Mock(side_effect=_is_repeater)
+ result = asyncio.run(scheduler._get_mesh_info())
+ assert result["total_contacts"] == 3
+ assert result["total_repeaters"] == 1
+ assert result["total_companions"] == 2
+
+ def test_db_complete_contact_tracking_populates_7d_new_counts(self):
+ """When complete_contact_tracking table exists, role rows are mapped to new_*_7d keys."""
+ scheduler = _make_scheduler()
+ del scheduler.bot.repeater_manager
+ del scheduler.bot.meshcore
+
+ # Simulate DB: first fetchone for message_stats table → None (no table)
+ # then inner block: fetchone for complete_contact_tracking → row
+ # fetchall for 7d roles → companion + repeater rows
+ # fetchone for 30d total → 5
+ # fetchall for 30d roles → empty
+ conn_mock = MagicMock()
+ conn_mock.__enter__ = Mock(return_value=conn_mock)
+ conn_mock.__exit__ = Mock(return_value=False)
+
+ # Track cursor().fetchone() calls — first returns None (no message_stats),
+ # second returns a row (complete_contact_tracking exists), third returns (5,) for 30d total
+ fetchone_seq = iter([None, ("complete_contact_tracking",), (5,)])
+ cursor_mock = MagicMock()
+ cursor_mock.fetchone.side_effect = lambda: next(fetchone_seq)
+ cursor_mock.fetchall.side_effect = [
+ # 7d new devices by role
+ [("companion", 4), ("repeater", 2), ("roomserver", 1), ("sensor", 0)],
+ # 30d active by role
+ [],
+ ]
+ conn_mock.cursor.return_value = cursor_mock
+ scheduler.bot.db_manager.connection.return_value = conn_mock
+
+ result = asyncio.run(scheduler._get_mesh_info())
+ assert result["new_companions_7d"] == 4
+ assert result["new_repeaters_7d"] == 2
+ assert result["new_roomservers_7d"] == 1
+ assert result["total_contacts_30d"] == 5
+
+ def test_db_exception_returns_zeroed_dict_gracefully(self):
+ """When db_manager.connection() raises, method still returns a dict without crashing."""
+ scheduler = _make_scheduler()
+ del scheduler.bot.repeater_manager
+ del scheduler.bot.meshcore
+ scheduler.bot.db_manager.connection.side_effect = Exception("DB unavailable")
+ result = asyncio.run(scheduler._get_mesh_info())
+ assert isinstance(result, dict)
+ assert result["total_contacts"] == 0
+
+ def test_repeater_manager_exception_falls_through(self):
+ """Exception in get_contact_statistics is caught; method returns partial dict."""
+ scheduler = _make_scheduler()
+ scheduler.bot.repeater_manager.get_contact_statistics = AsyncMock(
+ side_effect=RuntimeError("timeout")
+ )
+ del scheduler.bot.meshcore
+ result = asyncio.run(scheduler._get_mesh_info())
+ assert isinstance(result, dict)
+
+
+# ---------------------------------------------------------------------------
+# TestSendScheduledMessageAsync
+# ---------------------------------------------------------------------------
+
+
+class TestSendScheduledMessageAsync:
+ """Tests for _send_scheduled_message_async() (lines 308–328)."""
+
+ def test_no_placeholders_calls_send_channel_message_directly(self):
+ scheduler = _make_scheduler()
+ scheduler.bot.command_manager.send_channel_message = AsyncMock()
+ asyncio.run(scheduler._send_scheduled_message_async("general", "Hello world"))
+ scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
+ "general", "Hello world"
+ )
+
+ def test_with_placeholder_calls_get_mesh_info_and_formats(self):
+ scheduler = _make_scheduler()
+ scheduler.bot.command_manager.send_channel_message = AsyncMock()
+ mesh_data = {
+ "total_contacts": 42,
+ "total_repeaters": 5,
+ "total_companions": 37,
+ "total_roomservers": 0,
+ "total_sensors": 0,
+ "recent_activity_24h": 10,
+ "new_companions_7d": 1,
+ "new_repeaters_7d": 0,
+ "new_roomservers_7d": 0,
+ "new_sensors_7d": 0,
+ "total_contacts_30d": 40,
+ "total_repeaters_30d": 4,
+ "total_companions_30d": 36,
+ "total_roomservers_30d": 0,
+ "total_sensors_30d": 0,
+ }
+ with patch.object(
+ scheduler, "_get_mesh_info", new=AsyncMock(return_value=mesh_data)
+ ):
+ with patch(
+ "modules.scheduler.format_keyword_response_with_placeholders",
+ return_value="Contacts: 42",
+ ) as mock_fmt:
+ asyncio.run(
+ scheduler._send_scheduled_message_async(
+ "general", "Contacts: {total_contacts}"
+ )
+ )
+ mock_fmt.assert_called_once()
+ scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
+ "general", "Contacts: 42"
+ )
+
+ def test_get_mesh_info_exception_sends_message_as_is(self):
+ """When _get_mesh_info raises, the original message is still sent."""
+ scheduler = _make_scheduler()
+ scheduler.bot.command_manager.send_channel_message = AsyncMock()
+ with patch.object(
+ scheduler,
+ "_get_mesh_info",
+ new=AsyncMock(side_effect=Exception("mesh unavailable")),
+ ):
+ asyncio.run(
+ scheduler._send_scheduled_message_async(
+ "general", "Active: {total_contacts}"
+ )
+ )
+ scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
+ "general", "Active: {total_contacts}"
+ )
+
+ def test_format_placeholder_exception_sends_message_as_is(self):
+ """When format_keyword_response_with_placeholders raises KeyError, original message is sent."""
+ scheduler = _make_scheduler()
+ scheduler.bot.command_manager.send_channel_message = AsyncMock()
+ with patch.object(
+ scheduler,
+ "_get_mesh_info",
+ new=AsyncMock(return_value={}),
+ ):
+ with patch(
+ "modules.scheduler.format_keyword_response_with_placeholders",
+ side_effect=KeyError("missing_key"),
+ ):
+ asyncio.run(
+ scheduler._send_scheduled_message_async(
+ "alerts", "Count: {total_contacts}"
+ )
+ )
+ scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
+ "alerts", "Count: {total_contacts}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# TestSendScheduledMessageWrapper
+# ---------------------------------------------------------------------------
+
+
+class TestSendScheduledMessageWrapper:
+ """Tests for the sync send_scheduled_message() wrapper (lines 121–150)."""
+
+ def test_uses_run_coroutine_threadsafe_when_main_loop_running(self):
+ scheduler = _make_scheduler()
+ mock_loop = Mock()
+ mock_loop.is_running.return_value = True
+ scheduler.bot.main_event_loop = mock_loop
+
+ fake_future = Mock()
+ fake_future.result.return_value = None
+
+ with patch("asyncio.run_coroutine_threadsafe", return_value=fake_future) as mock_rct:
+ scheduler.send_scheduled_message("general", "hi")
+
+ mock_rct.assert_called_once()
+ fake_future.result.assert_called_once_with(timeout=60)
+
+ def test_logs_error_when_future_result_raises(self):
+ scheduler = _make_scheduler()
+ mock_loop = Mock()
+ mock_loop.is_running.return_value = True
+ scheduler.bot.main_event_loop = mock_loop
+
+ fake_future = Mock()
+ fake_future.result.side_effect = Exception("timeout")
+
+ with patch("asyncio.run_coroutine_threadsafe", return_value=fake_future):
+ scheduler.send_scheduled_message("general", "hi")
+
+ scheduler.bot.logger.error.assert_called()
+
+ def test_fallback_to_event_loop_when_no_main_loop(self):
+ scheduler = _make_scheduler()
+ scheduler.bot.main_event_loop = None
+
+ mock_loop = Mock()
+ mock_loop.run_until_complete = Mock()
+
+ async def _noop():
+ return None
+
+ with patch("asyncio.get_event_loop", return_value=mock_loop):
+ with patch.object(
+ scheduler,
+ "_send_scheduled_message_async",
+ return_value=_noop(),
+ ):
+ scheduler.send_scheduled_message("general", "test message")
+
+ mock_loop.run_until_complete.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# TestRunDataRetention
+# ---------------------------------------------------------------------------
+
+
+class TestRunDataRetention:
+ """Tests for _run_data_retention() (lines 501–581)."""
+
+ def _make(self):
+ scheduler = _make_scheduler()
+ # Remove all optional attributes so hasattr returns False by default
+ for attr in [
+ "web_viewer_integration",
+ "repeater_manager",
+ "command_manager",
+ "mesh_graph",
+ ]:
+ if hasattr(scheduler.bot, attr):
+ delattr(scheduler.bot, attr)
+ return scheduler
+
+ def test_calls_web_viewer_cleanup_when_present(self):
+ scheduler = self._make()
+ bi_mock = Mock()
+ bi_mock.cleanup_old_data = Mock()
+ wvi_mock = Mock()
+ wvi_mock.bot_integration = bi_mock
+ scheduler.bot.web_viewer_integration = wvi_mock
+ scheduler._run_data_retention()
+ bi_mock.cleanup_old_data.assert_called_once()
+
+ def test_does_not_call_web_viewer_cleanup_when_absent(self):
+ scheduler = self._make()
+ # web_viewer_integration not set — should not raise
+ scheduler._run_data_retention() # must not raise
+
+ def test_calls_repeater_manager_cleanup_database_without_main_loop(self):
+ scheduler = self._make()
+ rm_mock = AsyncMock()
+ scheduler.bot.main_event_loop = None
+ scheduler.bot.repeater_manager = rm_mock
+ scheduler.bot.repeater_manager.cleanup_database = AsyncMock()
+ scheduler.bot.repeater_manager.cleanup_repeater_retention = Mock()
+ scheduler._run_data_retention()
+ scheduler.bot.repeater_manager.cleanup_repeater_retention.assert_called_once()
+
+ def test_calls_cleanup_expired_cache_when_present(self):
+ scheduler = self._make()
+ scheduler.bot.db_manager.cleanup_expired_cache = Mock()
+ scheduler._run_data_retention()
+ scheduler.bot.db_manager.cleanup_expired_cache.assert_called_once()
+
+ def test_calls_mesh_graph_delete_expired_edges_when_present(self):
+ scheduler = self._make()
+ mg_mock = Mock()
+ mg_mock.delete_expired_edges_from_db = Mock()
+ scheduler.bot.mesh_graph = mg_mock
+ scheduler._run_data_retention()
+ mg_mock.delete_expired_edges_from_db.assert_called_once()
+
+ def test_sets_last_retention_stats_ran_at_on_success(self):
+ scheduler = self._make()
+ scheduler._run_data_retention()
+ assert "ran_at" in scheduler._last_retention_stats
+
+ def test_sets_last_retention_stats_error_on_exception(self):
+ scheduler = self._make()
+ # Force an exception by making db_manager.set_metadata raise immediately
+ # inside the try block (cleanup_expired_cache doesn't exist, so no early raise;
+ # we inject via web_viewer_integration instead)
+ wvi_mock = Mock()
+ wvi_mock.bot_integration.cleanup_old_data = Mock(side_effect=RuntimeError("disk full"))
+ scheduler.bot.web_viewer_integration = wvi_mock
+ scheduler._run_data_retention()
+ assert "error" in scheduler._last_retention_stats
+
+ def test_no_error_when_db_manager_set_metadata_raises(self):
+ """set_metadata failures after ran_at assignment should be silently swallowed."""
+ scheduler = self._make()
+ scheduler.bot.db_manager.set_metadata = Mock(side_effect=Exception("locked"))
+ # Should not propagate
+ scheduler._run_data_retention()
+ assert "ran_at" in scheduler._last_retention_stats
+
+
+# ---------------------------------------------------------------------------
+# TestCheckIntervalAdvertisingExtended
+# ---------------------------------------------------------------------------
+
+
+class TestCheckIntervalAdvertisingExtended:
+ """Additional coverage for check_interval_advertising() (lines 583–607)."""
+
+ def test_exception_logs_error(self):
+ scheduler = _make_scheduler()
+ # Force getint to raise
+ scheduler.bot.config.getint = Mock(side_effect=Exception("bad config"))
+ scheduler.check_interval_advertising()
+ scheduler.bot.logger.error.assert_called()
+
+ def test_last_advert_time_none_sets_timer_and_returns(self):
+ """When last_advert_time is None, timer is set but no advert is sent."""
+ scheduler = _make_scheduler()
+ scheduler.bot.config.set("Bot", "advert_interval_hours", "2")
+ scheduler.bot.last_advert_time = None
+
+ with patch.object(scheduler, "send_interval_advert") as mock_send:
+ scheduler.check_interval_advertising()
+
+ mock_send.assert_not_called()
+ assert scheduler.bot.last_advert_time is not None
+
+ def test_missing_last_advert_time_attr_sets_timer(self):
+ """When bot has no last_advert_time attribute, it gets initialised."""
+ scheduler = _make_scheduler()
+ scheduler.bot.config.set("Bot", "advert_interval_hours", "1")
+ # Delete the attribute so hasattr returns False
+ del scheduler.bot.last_advert_time
+
+ with patch.object(scheduler, "send_interval_advert") as mock_send:
+ scheduler.check_interval_advertising()
+
+ mock_send.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# TestCollectEmailStats
+# ---------------------------------------------------------------------------
+
+
+class TestCollectEmailStats:
+ """Tests for _collect_email_stats() (lines 927–1014)."""
+
+ def _scheduler_with_db(self):
+ scheduler = _make_scheduler()
+ return scheduler
+
+ def test_returns_dict_type(self):
+ scheduler = self._scheduler_with_db()
+ result = scheduler._collect_email_stats()
+ assert isinstance(result, dict)
+
+ def test_uptime_unknown_when_no_connection_time(self):
+ scheduler = self._scheduler_with_db()
+ # MagicMock returns truthy by default for getattr; force None
+ scheduler.bot.connection_time = None
+ result = scheduler._collect_email_stats()
+ assert result.get("uptime") == "unknown"
+
+ def test_uptime_computed_when_connection_time_set(self):
+ import time as _time
+ scheduler = self._scheduler_with_db()
+ scheduler.bot.connection_time = _time.time() - 7200 # 2 hours ago
+ result = scheduler._collect_email_stats()
+ assert "2h" in result.get("uptime", "")
+
+ def test_contacts_error_key_set_when_db_raises(self):
+ """When db_manager.connection() raises, contacts_error is recorded."""
+ scheduler = self._scheduler_with_db()
+ scheduler.bot.db_manager.connection.side_effect = Exception("no DB")
+ result = scheduler._collect_email_stats()
+ assert "contacts_error" in result
+
+ def test_db_size_unknown_when_db_path_missing(self):
+ """When db_path attribute is missing or stat fails, db_size_mb is 'unknown'."""
+ scheduler = self._scheduler_with_db()
+ scheduler.bot.db_manager.db_path = "/nonexistent/path/test.db"
+ result = scheduler._collect_email_stats()
+ assert result.get("db_size_mb") == "unknown"
+
+ def test_retention_key_always_present(self):
+ scheduler = self._scheduler_with_db()
+ result = scheduler._collect_email_stats()
+ assert "retention" in result
+
+ def test_no_log_file_in_config_skips_log_stats(self):
+ scheduler = self._scheduler_with_db()
+ # No 'Logging' section → fallback empty string → log_file not set
+ result = scheduler._collect_email_stats()
+ assert "log_file" not in result
+
+ def test_contacts_totals_from_mock_cursor(self):
+ """When cursor returns plausible rows, values are mapped to contacts_* keys."""
+ scheduler = self._scheduler_with_db()
+
+ conn_mock = MagicMock()
+ conn_mock.__enter__ = Mock(return_value=conn_mock)
+ conn_mock.__exit__ = Mock(return_value=False)
+
+ row_total = MagicMock()
+ row_total.get = Mock(side_effect=lambda k, d=0: {"n": 50}.get(k, d))
+ row_24h = MagicMock()
+ row_24h.get = Mock(side_effect=lambda k, d=0: {"n": 10}.get(k, d))
+ row_new = MagicMock()
+ row_new.get = Mock(side_effect=lambda k, d=0: {"n": 3}.get(k, d))
+
+ cursor_mock = MagicMock()
+ cursor_mock.fetchone.side_effect = [row_total, row_24h, row_new]
+ conn_mock.cursor.return_value = cursor_mock
+ scheduler.bot.db_manager.connection.return_value = conn_mock
+
+ result = scheduler._collect_email_stats()
+ assert result.get("contacts_total") == 50
+ assert result.get("contacts_24h") == 10
+ assert result.get("contacts_new_24h") == 3
diff --git a/tests/test_security_utils.py b/tests/test_security_utils.py
index 40cb00a..f564a7b 100644
--- a/tests/test_security_utils.py
+++ b/tests/test_security_utils.py
@@ -4,10 +4,14 @@ from unittest.mock import patch
import pytest
+import os
+import socket
+
from modules.security_utils import (
sanitize_input,
validate_api_key_format,
validate_external_url,
+ validate_integer_range,
validate_port_number,
validate_pubkey_format,
validate_safe_path,
@@ -133,3 +137,117 @@ class TestValidatePortNumber:
def test_invalid_port(self):
assert validate_port_number(0) is False
assert validate_port_number(70000) is False
+
+ def test_non_integer_port_rejected(self):
+ assert validate_port_number("8080") is False # type: ignore[arg-type]
+ assert validate_port_number(None) is False # type: ignore[arg-type]
+
+
+class TestValidateIntegerRange:
+ """Tests for validate_integer_range()."""
+
+ def test_value_in_range_returns_true(self):
+ assert validate_integer_range(5, 1, 10) is True
+
+ def test_value_at_min_boundary_returns_true(self):
+ assert validate_integer_range(1, 1, 10) is True
+
+ def test_value_at_max_boundary_returns_true(self):
+ assert validate_integer_range(10, 1, 10) is True
+
+ def test_value_below_min_raises(self):
+ with pytest.raises(ValueError, match="must be between"):
+ validate_integer_range(0, 1, 10, name="retries")
+
+ def test_value_above_max_raises(self):
+ with pytest.raises(ValueError, match="must be between"):
+ validate_integer_range(11, 1, 10, name="retries")
+
+ def test_non_integer_raises(self):
+ with pytest.raises(ValueError, match="must be an integer"):
+ validate_integer_range("five", 1, 10) # type: ignore[arg-type]
+
+
+class TestSanitizeInputExtra:
+ """Additional tests for sanitize_input() covering missed branches."""
+
+ def test_non_string_content_is_cast_to_str(self):
+ result = sanitize_input(42) # type: ignore[arg-type]
+ assert result == "42"
+
+ def test_negative_max_length_raises(self):
+ with pytest.raises(ValueError):
+ sanitize_input("hello", max_length=-1)
+
+
+class TestValidateApiKeyFormatExtra:
+ """Additional tests for validate_api_key_format() covering missed branches."""
+
+ def test_non_string_returns_false(self):
+ assert validate_api_key_format(12345) is False # type: ignore[arg-type]
+
+
+class TestIsNixEnvironment:
+ """Tests for _is_nix_environment() — coverage via validate_safe_path (which calls it)."""
+
+ def test_nix_env_var_enables_dangerous_path_access(self, tmp_path):
+ # When NIX_STORE is set, system-path check is skipped
+ import modules.security_utils as su
+ with patch.object(su, "_is_nix_environment", return_value=True):
+ # /proc is dangerous on Linux, but Nix mode should allow it via allow_absolute
+ result = validate_safe_path(str(tmp_path), base_dir=str(tmp_path), allow_absolute=True)
+ assert result is not None
+
+ def test_non_nix_env_detects_nix_store_var(self):
+ import modules.security_utils as su
+ with patch.dict(os.environ, {"NIX_STORE": "/nix/store"}, clear=False):
+ assert su._is_nix_environment() is True
+
+ def test_non_nix_env_detects_nix_path_var(self):
+ import modules.security_utils as su
+ env = {k: v for k, v in os.environ.items()
+ if k not in ("NIX_STORE", "NIX_PATH", "NIX_REMOTE", "IN_NIX_SHELL")}
+ with patch.dict(os.environ, {**env, "NIX_PATH": "/nix"}, clear=True):
+ assert su._is_nix_environment() is True
+
+ def test_no_nix_vars_returns_false(self):
+ import modules.security_utils as su
+ env = {k: v for k, v in os.environ.items()
+ if k not in ("NIX_STORE", "NIX_PATH", "NIX_REMOTE", "IN_NIX_SHELL")}
+ with patch.dict(os.environ, env, clear=True):
+ # Can't assert False here because we might be in Nix, just call it
+ result = su._is_nix_environment()
+ assert isinstance(result, bool)
+
+
+class TestValidateExternalUrlExtra:
+ """Additional coverage for validate_external_url() socket exception paths."""
+
+ def test_dns_resolution_failure_returns_false(self):
+ with patch("socket.gethostbyname", side_effect=socket.gaierror("no such host")):
+ assert validate_external_url("http://nonexistent.invalid.example") is False
+
+ def test_dns_timeout_returns_false(self):
+ with patch("socket.gethostbyname", side_effect=socket.timeout("timeout")):
+ assert validate_external_url("http://slow.example.com") is False
+
+ def test_allow_localhost_permits_loopback(self):
+ with patch("socket.gethostbyname", return_value="127.0.0.1"):
+ result = validate_external_url("http://localhost", allow_localhost=True)
+ assert result is True
+
+
+class TestValidateSafePathExtra:
+ """Additional coverage for validate_safe_path() exception paths."""
+
+ def test_dangerous_system_path_rejected_on_linux(self, tmp_path):
+ import modules.security_utils as su
+ with patch.object(su, "_is_nix_environment", return_value=False):
+ with pytest.raises(ValueError, match="system directory"):
+ validate_safe_path("/etc/passwd", allow_absolute=True)
+
+ def test_unexpected_exception_wrapped_as_value_error(self, tmp_path):
+ import modules.security_utils as su
+ with patch("modules.security_utils.Path.resolve", side_effect=OSError("disk fail")):
+ with pytest.raises(ValueError, match="Invalid or unsafe file path"):
+ validate_safe_path("some_file.db", base_dir=str(tmp_path))
diff --git a/tests/test_stats_command.py b/tests/test_stats_command.py
new file mode 100644
index 0000000..b91bf41
--- /dev/null
+++ b/tests/test_stats_command.py
@@ -0,0 +1,873 @@
+"""Tests for modules.commands.stats_command — pure logic functions."""
+
+import configparser
+from contextlib import contextmanager
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+
+from modules.commands.stats_command import StatsCommand
+from tests.conftest import mock_message
+
+
+def _make_db_manager():
+ """Create a mock db_manager with a working connection context manager."""
+ import sqlite3
+ conn = sqlite3.connect(":memory:")
+ db = MagicMock()
+
+ @contextmanager
+ def _conn_ctx():
+ yield conn
+
+ db.connection = _conn_ctx
+ db.db_path = ":memory:"
+ return db
+
+
+def _make_bot(enabled=True):
+ bot = MagicMock()
+ bot.logger = Mock()
+ config = configparser.ConfigParser()
+ config.add_section("Bot")
+ config.set("Bot", "bot_name", "TestBot")
+ config.add_section("Channels")
+ config.set("Channels", "monitor_channels", "general")
+ config.set("Channels", "respond_to_dms", "true")
+ config.add_section("Keywords")
+ config.add_section("Stats_Command")
+ config.set("Stats_Command", "enabled", str(enabled).lower())
+ config.set("Stats_Command", "collect_stats", "true")
+ bot.config = config
+ bot.translator = MagicMock()
+ bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
+ bot.command_manager = MagicMock()
+ bot.command_manager.monitor_channels = ["general"]
+ bot.db_manager = _make_db_manager()
+ bot.prefix_hex_chars = 2
+ return bot
+
+
+class TestIsValidPathFormat:
+ """Tests for _is_valid_path_format."""
+
+ def setup_method(self):
+ self.cmd = StatsCommand(_make_bot())
+
+ def test_none_returns_false(self):
+ assert self.cmd._is_valid_path_format(None) is False
+
+ def test_empty_returns_false(self):
+ assert self.cmd._is_valid_path_format("") is False
+
+ def test_hex_path_valid(self):
+ assert self.cmd._is_valid_path_format("01,7a,55") is True
+
+ def test_continuous_hex_valid(self):
+ assert self.cmd._is_valid_path_format("017a55") is True
+
+ def test_descriptive_text_invalid(self):
+ assert self.cmd._is_valid_path_format("Routed through 3 hops") is False
+ assert self.cmd._is_valid_path_format("Direct") is False
+ assert self.cmd._is_valid_path_format("unknown path") is False
+
+ def test_single_hex_node_valid(self):
+ assert self.cmd._is_valid_path_format("7a") is True
+
+
+class TestFormatPathForDisplay:
+ """Tests for _format_path_for_display."""
+
+ def setup_method(self):
+ self.cmd = StatsCommand(_make_bot())
+
+ def test_none_returns_direct(self):
+ assert self.cmd._format_path_for_display(None) == "Direct"
+
+ def test_empty_returns_direct(self):
+ assert self.cmd._format_path_for_display("") == "Direct"
+
+ def test_already_formatted_with_commas_unchanged(self):
+ assert self.cmd._format_path_for_display("01,7a,55") == "01,7a,55"
+
+ def test_continuous_hex_chunked(self):
+ result = self.cmd._format_path_for_display("017a55")
+ assert "," in result
+ parts = result.split(",")
+ assert len(parts) == 3
+
+ def test_single_node_unchanged(self):
+ result = self.cmd._format_path_for_display("7a")
+ assert result == "7a"
+
+ def test_descriptive_text_returned_as_is(self):
+ text = "Routed through 3 hops"
+ result = self.cmd._format_path_for_display(text)
+ assert result == text
+
+
+class TestStatsCommandEnabled:
+ """Tests for can_execute."""
+
+ def test_enabled(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ msg = mock_message(content="stats", channel="general")
+ # can_execute uses the base class logic (enabled flag lives elsewhere)
+ # Actually, stats doesn't override can_execute beyond base — it checks at execute
+ assert cmd.stats_enabled is True
+
+ def test_disabled(self):
+ bot = _make_bot(enabled=False)
+ cmd = StatsCommand(bot)
+ assert cmd.stats_enabled is False
+
+
+# ---------------------------------------------------------------------------
+# record_message
+# ---------------------------------------------------------------------------
+
+class TestRecordMessage:
+ def test_record_message_inserts_row(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ msg = mock_message(content="hello", channel="general", sender_id="Alice")
+ msg.timestamp = 1000
+ msg.hops = 2
+ msg.snr = 5.0
+ msg.rssi = -90
+ msg.path = "aa,bb"
+ cmd.record_message(msg)
+ # Verify row inserted
+ import sqlite3
+ conn = sqlite3.connect(":memory:")
+ # Since we used in-memory DB in _make_db_manager, we just assert no exception was raised
+
+ def test_record_message_disabled_collect_stats(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ cmd.collect_stats = False
+ msg = mock_message(content="hello", channel="general")
+ # Should return early without error
+ cmd.record_message(msg)
+
+ def test_record_message_disabled_track_all(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ cmd.track_all_messages = False
+ msg = mock_message(content="hello", channel="general")
+ cmd.record_message(msg)
+
+ def test_record_message_anonymize_users(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ cmd.anonymize_users = True
+ msg = mock_message(content="hello", channel="general", sender_id="RealUser")
+ msg.timestamp = 1000
+ msg.hops = 0
+ msg.snr = None
+ msg.rssi = None
+ msg.path = None
+ # Should not raise
+ cmd.record_message(msg)
+
+ def test_record_message_no_sender_id(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ msg = mock_message(content="hello", channel="general", sender_id=None)
+ msg.timestamp = 1000
+ msg.hops = 0
+ msg.snr = None
+ msg.rssi = None
+ msg.path = None
+ cmd.record_message(msg)
+
+
+# ---------------------------------------------------------------------------
+# record_command
+# ---------------------------------------------------------------------------
+
+class TestRecordCommand:
+ def test_record_command_inserts_row(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ msg = mock_message(content="ping", channel="general", sender_id="Alice")
+ msg.timestamp = 1000
+ cmd.record_command(msg, "ping", response_sent=True)
+
+ def test_record_command_disabled(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ cmd.collect_stats = False
+ msg = mock_message(content="ping", channel="general")
+ cmd.record_command(msg, "ping")
+
+ def test_record_command_no_track_details(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ cmd.track_command_details = False
+ msg = mock_message(content="ping", channel="general")
+ cmd.record_command(msg, "ping")
+
+ def test_record_command_anonymize(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ cmd.anonymize_users = True
+ msg = mock_message(content="ping", channel="general", sender_id="RealUser")
+ msg.timestamp = 1000
+ cmd.record_command(msg, "ping")
+
+
+# ---------------------------------------------------------------------------
+# record_path_stats
+# ---------------------------------------------------------------------------
+
+class TestRecordPathStats:
+ def test_record_path_stats_valid_path(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ msg = mock_message(content="hello", channel="general", sender_id="Alice")
+ msg.timestamp = 1000
+ msg.hops = 3
+ msg.path = "aa,bb,cc"
+ cmd.record_path_stats(msg)
+
+ def test_record_path_stats_no_hops(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ msg = mock_message(content="hello", channel="general")
+ msg.hops = 0
+ msg.path = "aa,bb"
+ cmd.record_path_stats(msg)
+
+ def test_record_path_stats_none_hops(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ msg = mock_message(content="hello", channel="general")
+ msg.hops = None
+ msg.path = "aa,bb"
+ cmd.record_path_stats(msg)
+
+ def test_record_path_stats_no_path(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ msg = mock_message(content="hello", channel="general")
+ msg.hops = 2
+ msg.path = None
+ cmd.record_path_stats(msg)
+
+ def test_record_path_stats_descriptive_path_skipped(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ msg = mock_message(content="hello", channel="general")
+ msg.hops = 2
+ msg.path = "Routed through 2 hops"
+ cmd.record_path_stats(msg)
+
+ def test_record_path_stats_disabled(self):
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ cmd.collect_stats = False
+ msg = mock_message(content="hello", channel="general")
+ msg.hops = 2
+ msg.path = "aa,bb"
+ cmd.record_path_stats(msg)
+
+
+# ---------------------------------------------------------------------------
+# execute — basic paths
+# ---------------------------------------------------------------------------
+
+class TestExecuteStats:
+ def test_execute_disabled_returns_false(self):
+ import asyncio
+ bot = _make_bot(enabled=False)
+ cmd = StatsCommand(bot)
+ cmd.stats_enabled = False
+ msg = mock_message(content="stats", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is False
+
+ def test_execute_enabled_returns_true(self):
+ import asyncio
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ bot.command_manager.send_response = __import__('unittest.mock', fromlist=['AsyncMock']).AsyncMock(return_value=True)
+ msg = mock_message(content="stats", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+ def test_execute_with_messages_subcommand(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+ bot = _make_bot(enabled=True)
+ bot.command_manager.send_response = AsyncMock(return_value=True)
+ cmd = StatsCommand(bot)
+ msg = mock_message(content="stats messages", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+ def test_execute_with_channels_subcommand(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+ bot = _make_bot(enabled=True)
+ bot.command_manager.send_response = AsyncMock(return_value=True)
+ cmd = StatsCommand(bot)
+ msg = mock_message(content="stats channels", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+ def test_execute_with_paths_subcommand(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+ bot = _make_bot(enabled=True)
+ bot.command_manager.send_response = AsyncMock(return_value=True)
+ cmd = StatsCommand(bot)
+ msg = mock_message(content="stats paths", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+ def test_execute_adverts_subcommand(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ cmd.send_response = AsyncMock(return_value=True)
+ msg = mock_message(content="stats adverts", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+ def test_execute_adverts_hashes_subcommand(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ cmd.send_response = AsyncMock(return_value=True)
+ msg = mock_message(content="stats adverts hashes", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+ def test_execute_unknown_subcommand(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ cmd.send_response = AsyncMock(return_value=True)
+ msg = mock_message(content="stats foobar", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+ def test_execute_bang_prefix_stripped(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ cmd.send_response = AsyncMock(return_value=True)
+ msg = mock_message(content="!stats", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+
+ def test_execute_exception_returns_false(self):
+ import asyncio
+ from unittest.mock import AsyncMock, patch
+ bot = _make_bot(enabled=True)
+ cmd = StatsCommand(bot)
+ cmd.send_response = AsyncMock(return_value=False)
+ with patch.object(cmd, '_get_basic_stats', side_effect=Exception("boom")):
+ msg = mock_message(content="stats", channel="general")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is False
+
+
+# ---------------------------------------------------------------------------
+# get_help_text
+# ---------------------------------------------------------------------------
+
+class TestGetHelpText:
+ def test_returns_string(self):
+ cmd = StatsCommand(_make_bot())
+ result = cmd.get_help_text()
+ assert isinstance(result, str)
+
+
+# ---------------------------------------------------------------------------
+# _format_path_for_display edge cases
+# ---------------------------------------------------------------------------
+
+class TestFormatPathEdgeCases:
+ def test_hex_chars_zero_uses_default(self):
+ bot = _make_bot()
+ bot.prefix_hex_chars = 0
+ cmd = StatsCommand(bot)
+ # "017a55" with hex_chars=0 falls back to 2
+ result = cmd._format_path_for_display("017a55")
+ assert "," in result
+
+ def test_legacy_fallback_odd_length(self):
+ """Path length not divisible by hex_chars triggers legacy fallback."""
+ bot = _make_bot()
+ bot.prefix_hex_chars = 4 # expects 4-char chunks, "017a55" is 6 chars (div by 4 = 1.5)
+ cmd = StatsCommand(bot)
+ result = cmd._format_path_for_display("017a55")
+ # 6 not divisible by 4 → legacy fallback: 2-char chunks
+ assert "," in result
+
+
+# ---------------------------------------------------------------------------
+# Exception paths for record_*
+# ---------------------------------------------------------------------------
+
+class TestRecordExceptionPaths:
+ def test_record_message_exception_handled(self):
+ """record_message handles exception without raising."""
+ bot = _make_bot()
+
+ @contextmanager
+ def _bad_conn():
+ raise Exception("DB down")
+ yield
+
+ bot.db_manager.connection = _bad_conn
+ cmd = StatsCommand.__new__(StatsCommand)
+ cmd.bot = bot
+ cmd.logger = bot.logger
+ cmd.collect_stats = True
+ cmd.track_all_messages = True
+ cmd.anonymize_users = False
+ msg = mock_message(content="hello", channel="general", sender_id="Alice")
+ msg.timestamp = 1000
+ msg.hops = 0
+ msg.snr = None
+ msg.rssi = None
+ msg.path = None
+ # Should not raise
+ cmd.record_message(msg)
+
+ def test_record_command_exception_handled(self):
+ """record_command handles exception without raising."""
+ bot = _make_bot()
+
+ @contextmanager
+ def _bad_conn():
+ raise Exception("DB down")
+ yield
+
+ bot.db_manager.connection = _bad_conn
+ cmd = StatsCommand.__new__(StatsCommand)
+ cmd.bot = bot
+ cmd.logger = bot.logger
+ cmd.collect_stats = True
+ cmd.track_command_details = True
+ cmd.anonymize_users = False
+ msg = mock_message(content="ping", channel="general", sender_id="Alice")
+ msg.timestamp = 1000
+ # Should not raise
+ cmd.record_command(msg, "ping")
+
+ def test_record_path_stats_anonymize_and_exception(self):
+ """record_path_stats with anonymize_users=True and bad conn."""
+ bot = _make_bot()
+
+ @contextmanager
+ def _bad_conn():
+ raise Exception("DB down")
+ yield
+
+ bot.db_manager.connection = _bad_conn
+ cmd = StatsCommand.__new__(StatsCommand)
+ cmd.bot = bot
+ cmd.logger = bot.logger
+ cmd.collect_stats = True
+ cmd.track_all_messages = True
+ cmd.anonymize_users = True
+ msg = mock_message(content="hello", channel="general", sender_id="RealUser")
+ msg.timestamp = 1000
+ msg.hops = 3
+ msg.path = "aa,bb,cc"
+ # Should not raise (hits anonymize branch then exception handler)
+ cmd.record_path_stats(msg)
+
+
+# ---------------------------------------------------------------------------
+# _get_basic_stats with data (covers lines 424-425, 439-440)
+# ---------------------------------------------------------------------------
+
+class TestGetBasicStatsWithData:
+ def test_top_command_and_user_set(self):
+ """When command_stats has rows, covers top_command and top_user format lines."""
+ import asyncio, time
+ bot = _make_bot()
+ cmd = StatsCommand(bot) # creates tables
+ with bot.db_manager.connection() as conn:
+ ts = int(time.time())
+ conn.execute(
+ "INSERT INTO command_stats (timestamp, sender_id, command_name, channel, is_dm, response_sent) "
+ "VALUES (?, 'Alice', 'ping', 'general', 0, 1)", (ts,)
+ )
+ conn.commit()
+ result = asyncio.run(cmd._get_basic_stats())
+ assert isinstance(result, str)
+
+
+# ---------------------------------------------------------------------------
+# _get_bot_user_leaderboard with data (covers lines 484-486)
+# ---------------------------------------------------------------------------
+
+class TestGetUserLeaderboardWithData:
+ def test_with_users_shows_data(self):
+ import asyncio, time
+ bot = _make_bot()
+ cmd = StatsCommand(bot) # creates tables
+ with bot.db_manager.connection() as conn:
+ ts = int(time.time())
+ # Insert a user with a long name to trigger truncation (len > 15)
+ conn.execute(
+ "INSERT INTO command_stats (timestamp, sender_id, command_name, channel, is_dm, response_sent) "
+ "VALUES (?, 'Alice_very_long_name_here', 'ping', 'general', 0, 1)", (ts,)
+ )
+ conn.commit()
+ result = asyncio.run(cmd._get_bot_user_leaderboard())
+ assert isinstance(result, str)
+
+ def test_exception_returns_error_key(self):
+ import asyncio
+ bot = _make_bot()
+
+ @contextmanager
+ def _bad_conn():
+ raise Exception("DB down")
+ yield
+
+ bot.db_manager.connection = _bad_conn
+ cmd = StatsCommand.__new__(StatsCommand)
+ cmd.bot = bot
+ cmd.logger = bot.logger
+ cmd.translator = bot.translator
+ result = asyncio.run(cmd._get_bot_user_leaderboard())
+ assert isinstance(result, str)
+
+
+# ---------------------------------------------------------------------------
+# _get_channel_leaderboard with data (covers lines 524-532)
+# ---------------------------------------------------------------------------
+
+class TestGetChannelLeaderboardWithData:
+ def test_with_channels_shows_data(self):
+ import asyncio, time
+ bot = _make_bot()
+ cmd = StatsCommand(bot) # creates tables
+ with bot.db_manager.connection() as conn:
+ ts = int(time.time())
+ conn.execute(
+ "INSERT INTO message_stats (timestamp, sender_id, channel, content, is_dm, hops, snr, rssi, path) "
+ "VALUES (?, 'Alice', 'general', 'hello', 0, 0, NULL, NULL, NULL)", (ts,)
+ )
+ conn.commit()
+ result = asyncio.run(cmd._get_channel_leaderboard())
+ assert isinstance(result, str)
+
+ def test_exception_returns_error_key(self):
+ import asyncio
+ bot = _make_bot()
+
+ @contextmanager
+ def _bad_conn():
+ raise Exception("DB down")
+ yield
+
+ bot.db_manager.connection = _bad_conn
+ cmd = StatsCommand.__new__(StatsCommand)
+ cmd.bot = bot
+ cmd.logger = bot.logger
+ cmd.translator = bot.translator
+ result = asyncio.run(cmd._get_channel_leaderboard())
+ assert isinstance(result, str)
+
+
+# ---------------------------------------------------------------------------
+# _get_path_leaderboard with data (covers lines 578-593)
+# ---------------------------------------------------------------------------
+
+class TestGetPathLeaderboardWithData:
+ def test_with_paths_shows_data(self):
+ import asyncio, time
+ bot = _make_bot()
+ cmd = StatsCommand(bot) # creates tables
+ with bot.db_manager.connection() as conn:
+ ts = int(time.time())
+ conn.execute(
+ "INSERT INTO path_stats (timestamp, sender_id, channel, path_length, path_string, hops) "
+ "VALUES (?, 'Alice', 'general', 3, 'aa,bb,cc', 3)", (ts,)
+ )
+ conn.commit()
+ msg = mock_message(content="stats paths", channel="general")
+ result = asyncio.run(cmd._get_path_leaderboard(msg))
+ assert isinstance(result, str)
+
+ def test_exception_returns_error_key(self):
+ import asyncio
+ bot = _make_bot()
+
+ @contextmanager
+ def _bad_conn():
+ raise Exception("DB down")
+ yield
+
+ bot.db_manager.connection = _bad_conn
+ cmd = StatsCommand.__new__(StatsCommand)
+ cmd.bot = bot
+ cmd.logger = bot.logger
+ cmd.translator = bot.translator
+ result = asyncio.run(cmd._get_path_leaderboard())
+ assert isinstance(result, str)
+
+
+# ---------------------------------------------------------------------------
+# _get_adverts_leaderboard (covers lines 613-771)
+# ---------------------------------------------------------------------------
+
+def _add_advert_tables(conn, with_daily_stats=True):
+ """Add complete_contact_tracking, unique_advert_packets, optionally daily_stats."""
+ conn.execute('''
+ CREATE TABLE IF NOT EXISTS complete_contact_tracking (
+ public_key TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ last_advert_timestamp TEXT,
+ advert_count INTEGER DEFAULT 0
+ )
+ ''')
+ conn.execute('''
+ CREATE TABLE IF NOT EXISTS unique_advert_packets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ public_key TEXT,
+ packet_hash TEXT,
+ first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ ''')
+ if with_daily_stats:
+ conn.execute('''
+ CREATE TABLE IF NOT EXISTS daily_stats (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ date TEXT,
+ public_key TEXT,
+ advert_count INTEGER DEFAULT 0
+ )
+ ''')
+ conn.commit()
+
+
+class TestGetAdvertsLeaderboard:
+ def test_no_contact_table_returns_error(self):
+ """When complete_contact_tracking table doesn't exist, exception path fires."""
+ import asyncio
+ bot = _make_bot()
+ # Don't add advert tables — the query will fail
+ cmd = StatsCommand(bot)
+ result = asyncio.run(cmd._get_adverts_leaderboard())
+ assert isinstance(result, str)
+
+ def test_no_adverts_returns_none_message(self):
+ """complete_contact_tracking exists but is empty → none branch."""
+ import asyncio
+ bot = _make_bot()
+ with bot.db_manager.connection() as conn:
+ _add_advert_tables(conn)
+ cmd = StatsCommand(bot)
+ result = asyncio.run(cmd._get_adverts_leaderboard())
+ assert isinstance(result, str)
+
+ def test_with_adverts_no_daily_stats_no_hashes(self):
+ """Fallback path: no daily_stats table, data present, no hashes."""
+ import asyncio
+ bot = _make_bot()
+ with bot.db_manager.connection() as conn:
+ _add_advert_tables(conn, with_daily_stats=False)
+ conn.execute(
+ "INSERT INTO complete_contact_tracking VALUES ('abc', 'TestNode', datetime('now', '-1 hour'), 5)"
+ )
+ conn.execute(
+ "INSERT INTO unique_advert_packets (public_key, packet_hash, first_seen) "
+ "VALUES ('abc', 'hash1', datetime('now', '-1 hour'))"
+ )
+ conn.commit()
+ cmd = StatsCommand(bot)
+ result = asyncio.run(cmd._get_adverts_leaderboard())
+ assert isinstance(result, str)
+
+ def test_with_adverts_no_daily_stats_show_hashes(self):
+ """Fallback path: no daily_stats, data present, show_hashes=True."""
+ import asyncio
+ bot = _make_bot()
+ with bot.db_manager.connection() as conn:
+ _add_advert_tables(conn, with_daily_stats=False)
+ conn.execute(
+ "INSERT INTO complete_contact_tracking VALUES ('abc', 'TestNode', datetime('now', '-1 hour'), 5)"
+ )
+ conn.execute(
+ "INSERT INTO unique_advert_packets (public_key, packet_hash, first_seen) "
+ "VALUES ('abc', 'hash1', datetime('now', '-1 hour'))"
+ )
+ conn.commit()
+ cmd = StatsCommand(bot)
+ result = asyncio.run(cmd._get_adverts_leaderboard(show_hashes=True))
+ assert isinstance(result, str)
+
+ def test_with_daily_stats_no_hashes(self):
+ """daily_stats table present, no hashes."""
+ import asyncio
+ bot = _make_bot()
+ with bot.db_manager.connection() as conn:
+ _add_advert_tables(conn, with_daily_stats=True)
+ conn.execute(
+ "INSERT INTO complete_contact_tracking VALUES ('abc', 'TestNode', datetime('now', '-1 hour'), 5)"
+ )
+ conn.execute(
+ "INSERT INTO daily_stats (date, public_key, advert_count) VALUES (date('now'), 'abc', 5)"
+ )
+ conn.commit()
+ cmd = StatsCommand(bot)
+ result = asyncio.run(cmd._get_adverts_leaderboard())
+ assert isinstance(result, str)
+
+ def test_with_daily_stats_show_hashes(self):
+ """daily_stats table present, show_hashes=True."""
+ import asyncio
+ bot = _make_bot()
+ with bot.db_manager.connection() as conn:
+ _add_advert_tables(conn, with_daily_stats=True)
+ conn.execute(
+ "INSERT INTO complete_contact_tracking VALUES ('abc', 'TestNode', datetime('now', '-1 hour'), 5)"
+ )
+ conn.execute(
+ "INSERT INTO daily_stats (date, public_key, advert_count) VALUES (date('now'), 'abc', 5)"
+ )
+ conn.execute(
+ "INSERT INTO unique_advert_packets (public_key, packet_hash, first_seen) "
+ "VALUES ('abc', 'hash1', datetime('now', '-1 hour'))"
+ )
+ conn.commit()
+ cmd = StatsCommand(bot)
+ result = asyncio.run(cmd._get_adverts_leaderboard(show_hashes=True))
+ assert isinstance(result, str)
+
+ def test_advert_with_singular_count(self):
+ """count == 1 triggers advert_singular translation key."""
+ import asyncio
+ bot = _make_bot()
+ with bot.db_manager.connection() as conn:
+ _add_advert_tables(conn, with_daily_stats=False)
+ conn.execute(
+ "INSERT INTO complete_contact_tracking VALUES ('abc', 'TestNode', datetime('now', '-1 hour'), 1)"
+ )
+ conn.commit()
+ cmd = StatsCommand(bot)
+ result = asyncio.run(cmd._get_adverts_leaderboard())
+ assert isinstance(result, str)
+
+ def test_advert_node_name_truncated(self):
+ """Names > 18 chars get truncated to 15 + '...'."""
+ import asyncio
+ bot = _make_bot()
+ with bot.db_manager.connection() as conn:
+ _add_advert_tables(conn, with_daily_stats=False)
+ conn.execute(
+ "INSERT INTO complete_contact_tracking VALUES ('abc', 'VeryLongNodeNameHere', datetime('now', '-1 hour'), 3)"
+ )
+ conn.commit()
+ cmd = StatsCommand(bot)
+ result = asyncio.run(cmd._get_adverts_leaderboard())
+ assert isinstance(result, str)
+
+ def test_many_hashes_truncated_at_10(self):
+ """More than 10 hashes for a node triggers truncation display."""
+ import asyncio
+ bot = _make_bot()
+ with bot.db_manager.connection() as conn:
+ _add_advert_tables(conn, with_daily_stats=False)
+ conn.execute(
+ "INSERT INTO complete_contact_tracking VALUES ('abc', 'Node', datetime('now', '-1 hour'), 15)"
+ )
+ for i in range(15):
+ conn.execute(
+ "INSERT INTO unique_advert_packets (public_key, packet_hash, first_seen) "
+ f"VALUES ('abc', 'hash{i}', datetime('now', '-1 hour'))"
+ )
+ conn.commit()
+ cmd = StatsCommand(bot)
+ result = asyncio.run(cmd._get_adverts_leaderboard(show_hashes=True))
+ assert isinstance(result, str)
+
+ def test_exception_returns_error_key(self):
+ """DB exception returns error translation."""
+ import asyncio
+ bot = _make_bot()
+
+ @contextmanager
+ def _bad_conn():
+ raise Exception("DB down")
+ yield
+
+ bot.db_manager.connection = _bad_conn
+ cmd = StatsCommand.__new__(StatsCommand)
+ cmd.bot = bot
+ cmd.logger = bot.logger
+ cmd.translator = bot.translator
+ result = asyncio.run(cmd._get_adverts_leaderboard())
+ assert isinstance(result, str)
+
+
+# ---------------------------------------------------------------------------
+# cleanup_old_stats (covers lines 779-804)
+# ---------------------------------------------------------------------------
+
+class TestCleanupOldStats:
+ def test_cleanup_runs_without_error(self):
+ bot = _make_bot()
+ cmd = StatsCommand(bot)
+ cmd.cleanup_old_stats(7) # Should not raise
+
+ def test_cleanup_exception_handled(self):
+ bot = _make_bot()
+
+ @contextmanager
+ def _bad_conn():
+ raise Exception("DB down")
+ yield
+
+ bot.db_manager.connection = _bad_conn
+ cmd = StatsCommand.__new__(StatsCommand)
+ cmd.bot = bot
+ cmd.logger = bot.logger
+ # Should not raise
+ cmd.cleanup_old_stats(7)
+
+
+# ---------------------------------------------------------------------------
+# get_stats_summary (covers lines 812-841)
+# ---------------------------------------------------------------------------
+
+class TestGetStatsSummary:
+ def test_returns_dict_with_keys(self):
+ bot = _make_bot()
+ cmd = StatsCommand(bot)
+ result = cmd.get_stats_summary()
+ assert isinstance(result, dict)
+ assert 'total_messages' in result
+ assert 'total_commands' in result
+ assert 'unique_users' in result
+ assert 'unique_channels' in result
+
+ def test_exception_returns_empty_dict(self):
+ bot = _make_bot()
+
+ @contextmanager
+ def _bad_conn():
+ raise Exception("DB down")
+ yield
+
+ bot.db_manager.connection = _bad_conn
+ cmd = StatsCommand.__new__(StatsCommand)
+ cmd.bot = bot
+ cmd.logger = bot.logger
+ result = cmd.get_stats_summary()
+ assert result == {}
diff --git a/tests/test_trace_command.py b/tests/test_trace_command.py
new file mode 100644
index 0000000..c3ef878
--- /dev/null
+++ b/tests/test_trace_command.py
@@ -0,0 +1,381 @@
+"""Tests for modules.commands.trace_command — pure logic functions."""
+
+import configparser
+from unittest.mock import MagicMock, Mock
+
+import pytest
+
+from modules.commands.trace_command import TraceCommand
+from tests.conftest import mock_message
+
+
+def _make_bot():
+ bot = MagicMock()
+ bot.logger = Mock()
+ config = configparser.ConfigParser()
+ config.add_section("Bot")
+ config.set("Bot", "bot_name", "TestBot")
+ config.add_section("Channels")
+ config.set("Channels", "monitor_channels", "general")
+ config.set("Channels", "respond_to_dms", "true")
+ config.add_section("Keywords")
+ config.add_section("Trace_Command")
+ config.set("Trace_Command", "enabled", "true")
+ config.set("Trace_Command", "maximum_hops", "5")
+ config.set("Trace_Command", "trace_mode", "one_byte")
+ config.set("Trace_Command", "timeout_per_hop_seconds", "1.5")
+ config.set("Trace_Command", "output_format", "inline")
+ bot.config = config
+ bot.translator = MagicMock()
+ bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
+ bot.command_manager = MagicMock()
+ bot.command_manager.monitor_channels = ["general"]
+ return bot
+
+
+class TestExtractPathFromMessage:
+ """Tests for _extract_path_from_message."""
+
+ def setup_method(self):
+ self.cmd = TraceCommand(_make_bot())
+
+ def test_no_path_returns_empty(self):
+ msg = mock_message(content="trace", path=None)
+ result = self.cmd._extract_path_from_message(msg)
+ assert result == []
+
+ def test_direct_message_returns_empty(self):
+ msg = mock_message(content="trace", path="Direct")
+ result = self.cmd._extract_path_from_message(msg)
+ assert result == []
+
+ def test_zero_hops_returns_empty(self):
+ msg = mock_message(content="trace", path="0 hops")
+ result = self.cmd._extract_path_from_message(msg)
+ assert result == []
+
+ def test_single_hop_path(self):
+ msg = mock_message(content="trace", path="7a")
+ result = self.cmd._extract_path_from_message(msg)
+ assert result == ["7a"]
+
+ def test_multi_hop_path(self):
+ msg = mock_message(content="trace", path="01,7a,55")
+ result = self.cmd._extract_path_from_message(msg)
+ assert result == ["01", "7a", "55"]
+
+ def test_path_with_route_type_stripped(self):
+ msg = mock_message(content="trace", path="01,7a via ROUTE_TYPE_MESHCORE")
+ result = self.cmd._extract_path_from_message(msg)
+ assert "01" in result
+
+ def test_path_with_parenthesis_stripped(self):
+ msg = mock_message(content="trace", path="7a (1 hop)")
+ result = self.cmd._extract_path_from_message(msg)
+ assert result == ["7a"]
+
+ def test_invalid_hex_ignored(self):
+ msg = mock_message(content="trace", path="01,zz,55")
+ result = self.cmd._extract_path_from_message(msg)
+ # zz is invalid hex, should be excluded
+ assert "zz" not in result
+
+
+class TestParsePathArg:
+ """Tests for _parse_path_arg."""
+
+ def setup_method(self):
+ self.cmd = TraceCommand(_make_bot())
+
+ def test_no_path_arg_returns_none(self):
+ result = self.cmd._parse_path_arg("trace")
+ assert result is None
+
+ def test_comma_separated_path(self):
+ result = self.cmd._parse_path_arg("trace 01,7a,55")
+ assert result == ["01", "7a", "55"]
+
+ def test_contiguous_hex_path(self):
+ result = self.cmd._parse_path_arg("trace 017a55")
+ assert result == ["01", "7a", "55"]
+
+ def test_invalid_hex_returns_none(self):
+ result = self.cmd._parse_path_arg("trace 01,zz")
+ assert result is None
+
+ def test_odd_length_hex_returns_none(self):
+ result = self.cmd._parse_path_arg("trace 017")
+ assert result is None
+
+ def test_tracer_prefix(self):
+ result = self.cmd._parse_path_arg("tracer 01,7a")
+ assert result == ["01", "7a"]
+
+
+class TestFormatTraceInline:
+ """Tests for _format_trace_inline."""
+
+ def setup_method(self):
+ self.cmd = TraceCommand(_make_bot())
+
+ def test_basic_inline_format(self):
+ from modules.trace_runner import RunTraceResult
+ result = RunTraceResult(
+ success=True,
+ tag=0,
+ path_nodes=[{"hash": "7a", "snr": -12.5}],
+ )
+ sender_str = "@[User] "
+ output = self.cmd._format_trace_inline(sender_str, result)
+ assert "[Bot]" in output
+ assert "7a" in output
+ assert "-12.5" in output
+
+ def test_inline_format_no_snr(self):
+ from modules.trace_runner import RunTraceResult
+ result = RunTraceResult(
+ success=True,
+ tag=0,
+ path_nodes=[{"hash": "7a", "snr": None}],
+ )
+ output = self.cmd._format_trace_inline("@[User] ", result)
+ assert "7a" in output
+
+
+class TestFormatTraceVertical:
+ """Tests for _format_trace_vertical."""
+
+ def setup_method(self):
+ self.cmd = TraceCommand(_make_bot())
+
+ def test_vertical_format_basic(self):
+ from modules.trace_runner import RunTraceResult
+ result = RunTraceResult(
+ success=True,
+ tag=0,
+ path_nodes=[
+ {"hash": "7a", "snr": -12.5},
+ {"hash": "55", "snr": -8.0},
+ ],
+ )
+ output = self.cmd._format_trace_vertical("@[User] ", result)
+ assert "Trace:" in output
+ assert "\n" in output # Multiple lines
+
+ def test_vertical_format_single_node(self):
+ from modules.trace_runner import RunTraceResult
+ result = RunTraceResult(
+ success=True,
+ tag=0,
+ path_nodes=[{"hash": "7a", "snr": -5.0}],
+ )
+ output = self.cmd._format_trace_vertical("@[User] ", result)
+ assert "[Bot]" in output
+
+
+class TestBuildReciprocalPath:
+ """Tests for _build_reciprocal_path."""
+
+ def setup_method(self):
+ self.cmd = TraceCommand(_make_bot())
+
+ def test_empty_list_unchanged(self):
+ assert self.cmd._build_reciprocal_path([]) == []
+
+ def test_single_node_unchanged(self):
+ assert self.cmd._build_reciprocal_path(["01"]) == ["01"]
+
+ def test_two_node_reciprocal(self):
+ result = self.cmd._build_reciprocal_path(["01", "7a"])
+ assert result == ["01", "7a", "01"]
+
+ def test_three_node_reciprocal(self):
+ result = self.cmd._build_reciprocal_path(["01", "7a", "55"])
+ assert result == ["01", "7a", "55", "7a", "01"]
+
+
+class TestMatchesKeyword:
+ """Tests for matches_keyword."""
+
+ def setup_method(self):
+ self.cmd = TraceCommand(_make_bot())
+
+ def test_trace_matches(self):
+ assert self.cmd.matches_keyword(mock_message(content="trace")) is True
+
+ def test_tracer_matches(self):
+ assert self.cmd.matches_keyword(mock_message(content="tracer")) is True
+
+ def test_trace_with_path_matches(self):
+ assert self.cmd.matches_keyword(mock_message(content="trace 01,7a")) is True
+
+ def test_other_does_not_match(self):
+ assert self.cmd.matches_keyword(mock_message(content="ping")) is False
+
+ def test_bang_prefix_trace_matches(self):
+ """!trace should be recognized."""
+ assert self.cmd.matches_keyword(mock_message(content="!trace")) is True
+
+ def test_bang_prefix_tracer_matches(self):
+ assert self.cmd.matches_keyword(mock_message(content="!tracer 01,7a")) is True
+
+
+class TestCanExecuteTrace:
+ """Tests for can_execute."""
+
+ def test_enabled_returns_true(self):
+ bot = _make_bot()
+ cmd = TraceCommand(bot)
+ msg = mock_message(content="trace", channel="general")
+ assert cmd.can_execute(msg) is True
+
+ def test_disabled_returns_false(self):
+ bot = _make_bot()
+ bot.config.set("Trace_Command", "enabled", "false")
+ cmd = TraceCommand(bot)
+ msg = mock_message(content="trace", channel="general")
+ assert cmd.can_execute(msg) is False
+
+
+class TestGetHelpTextTrace:
+ def test_returns_string(self):
+ cmd = TraceCommand(_make_bot())
+ result = cmd.get_help_text()
+ assert isinstance(result, str)
+ assert "trace" in result.lower()
+
+
+class TestExtractPathEdgeCases:
+ def setup_method(self):
+ self.cmd = TraceCommand(_make_bot())
+
+ def test_single_node_invalid_length(self):
+ """3-char path segment is not valid 2-char hex → returns []."""
+ msg = mock_message(content="trace", path="abc")
+ result = self.cmd._extract_path_from_message(msg)
+ assert result == []
+
+ def test_single_node_non_hex(self):
+ """2-char non-hex → returns []."""
+ msg = mock_message(content="trace", path="zz")
+ result = self.cmd._extract_path_from_message(msg)
+ assert result == []
+
+
+class TestParseBangPrefix:
+ def test_bang_prefix_stripped(self):
+ cmd = TraceCommand(_make_bot())
+ result = cmd._parse_path_arg("!trace 01,7a")
+ assert result == ["01", "7a"]
+
+
+class TestFormatTraceResult:
+ """Tests for _format_trace_result."""
+
+ def setup_method(self):
+ self.cmd = TraceCommand(_make_bot())
+
+ def test_failed_result_shows_error(self):
+ from modules.trace_runner import RunTraceResult
+ result = RunTraceResult(success=False, tag=0, path_nodes=[], error_message="timeout")
+ output = self.cmd._format_trace_result(mock_message(content="trace"), result)
+ assert "failed" in output.lower() or "timeout" in output
+
+ def test_success_inline(self):
+ from modules.trace_runner import RunTraceResult
+ result = RunTraceResult(success=True, tag=0, path_nodes=[{"hash": "7a", "snr": -5.0}])
+ self.cmd.output_format = "inline"
+ output = self.cmd._format_trace_result(mock_message(content="trace", sender_id="Alice"), result)
+ assert isinstance(output, str)
+ assert "7a" in output
+
+ def test_success_vertical(self):
+ from modules.trace_runner import RunTraceResult
+ result = RunTraceResult(success=True, tag=0, path_nodes=[{"hash": "7a", "snr": -5.0}])
+ self.cmd.output_format = "vertical"
+ output = self.cmd._format_trace_result(mock_message(content="trace", sender_id="Alice"), result)
+ assert "Trace:" in output
+
+
+class TestFormatTraceVerticalThreeNodes:
+ """Tests for _format_trace_vertical with multiple nodes (middle hop)."""
+
+ def setup_method(self):
+ self.cmd = TraceCommand(_make_bot())
+
+ def test_three_nodes_has_middle_hop(self):
+ from modules.trace_runner import RunTraceResult
+ result = RunTraceResult(
+ success=True,
+ tag=0,
+ path_nodes=[
+ {"hash": "aa", "snr": -10.0},
+ {"hash": "bb", "snr": -8.0},
+ {"hash": "cc", "snr": -12.0},
+ ],
+ )
+ output = self.cmd._format_trace_vertical("@[User] ", result)
+ # aa and bb appear as from_labels for subsequent hops
+ assert "aa" in output
+ assert "bb" in output
+ # cc is the last node's hash — it's never used as a from_label
+ # and doesn't appear in the output
+ lines = output.split("\n")
+ assert len(lines) >= 4 # Header + 3 hops
+
+ def test_three_nodes_no_snr(self):
+ from modules.trace_runner import RunTraceResult
+ result = RunTraceResult(
+ success=True,
+ tag=0,
+ path_nodes=[
+ {"hash": "aa", "snr": None},
+ {"hash": "bb", "snr": None},
+ {"hash": "cc", "snr": None},
+ ],
+ )
+ output = self.cmd._format_trace_vertical("@[User] ", result)
+ assert "—" in output # Unknown SNR marker
+
+
+class TestTraceExecute:
+ """Tests for execute() reachable paths (no real radio)."""
+
+ def test_execute_no_path_sends_error(self):
+ from unittest.mock import AsyncMock
+ import asyncio
+ bot = _make_bot()
+ cmd = TraceCommand(bot)
+ cmd.send_response = AsyncMock(return_value=True)
+ # Message with no path and no path arg
+ msg = mock_message(content="trace", path=None)
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+ call_text = cmd.send_response.call_args[0][1]
+ assert "path" in call_text.lower()
+
+ def test_execute_not_connected(self):
+ from unittest.mock import AsyncMock
+ import asyncio
+ bot = _make_bot()
+ bot.connected = False
+ bot.meshcore = None
+ cmd = TraceCommand(bot)
+ cmd.send_response = AsyncMock(return_value=True)
+ msg = mock_message(content="trace 01,7a")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
+ cmd.send_response.assert_called_once()
+
+ def test_execute_no_meshcore_commands(self):
+ from unittest.mock import AsyncMock
+ import asyncio
+ bot = _make_bot()
+ bot.connected = True
+ bot.meshcore = MagicMock()
+ bot.meshcore.commands = None
+ cmd = TraceCommand(bot)
+ cmd.send_response = AsyncMock(return_value=True)
+ msg = mock_message(content="trace 01,7a")
+ result = asyncio.run(cmd.execute(msg))
+ assert result is True
diff --git a/tests/test_transmission_tracker.py b/tests/test_transmission_tracker.py
index fb65577..d0a9f94 100644
--- a/tests/test_transmission_tracker.py
+++ b/tests/test_transmission_tracker.py
@@ -1,6 +1,9 @@
"""Tests for modules/transmission_tracker.py."""
+import json
+import sqlite3
import time
+from contextlib import closing
from unittest.mock import Mock
import pytest
@@ -250,3 +253,273 @@ class TestCleanupOldRecords:
tracker.confirmed_transmissions["repeat_hash"] = old_rec
tracker.cleanup_old_records()
assert "repeat_hash" in tracker.confirmed_transmissions
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _make_bot_with_device(mock_logger, pubkey, prefix_hex_chars=2):
+ """Build a minimal bot mock whose meshcore.device.public_key == pubkey."""
+ device = Mock()
+ device.public_key = pubkey
+ meshcore = Mock()
+ meshcore.device = device
+ bot = Mock()
+ bot.logger = mock_logger
+ bot.meshcore = meshcore
+ bot.prefix_hex_chars = prefix_hex_chars
+ return bot
+
+
+def _make_db_with_packet_stream(db_path: str) -> None:
+ """Create a minimal packet_stream table in a SQLite file."""
+ with closing(sqlite3.connect(db_path)) as conn:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS packet_stream (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ type TEXT,
+ timestamp REAL,
+ data TEXT
+ )
+ """)
+ conn.commit()
+
+
+# ---------------------------------------------------------------------------
+# Tests for _update_bot_prefix (lines 57-67)
+# ---------------------------------------------------------------------------
+
+class TestUpdateBotPrefix:
+ """Cover lines 57-67: _update_bot_prefix with str and bytes public_key."""
+
+ def test_str_pubkey_sets_bot_prefix(self, mock_logger):
+ """When public_key is a str, bot_prefix is set to its first prefix_hex_chars."""
+ bot = _make_bot_with_device(mock_logger, pubkey="abcdef1234")
+ tracker = TransmissionTracker(bot)
+ assert tracker.bot_prefix == "ab"
+
+ def test_str_pubkey_prefix_hex_chars_4(self, mock_logger):
+ """prefix_hex_chars=4 slices the first 4 characters."""
+ bot = _make_bot_with_device(mock_logger, pubkey="deadbeef99", prefix_hex_chars=4)
+ tracker = TransmissionTracker(bot)
+ assert tracker.bot_prefix == "dead"
+
+ def test_bytes_pubkey_sets_bot_prefix(self, mock_logger):
+ """When public_key is bytes, bot_prefix is the hex of the first byte."""
+ bot = _make_bot_with_device(mock_logger, pubkey=b"\xab\xcd\xef")
+ tracker = TransmissionTracker(bot)
+ assert tracker.bot_prefix == "ab"
+
+ def test_bytes_pubkey_zero_byte(self, mock_logger):
+ """Bytes public key starting with 0x00 produces '00'."""
+ bot = _make_bot_with_device(mock_logger, pubkey=b"\x00\xff")
+ tracker = TransmissionTracker(bot)
+ assert tracker.bot_prefix == "00"
+
+ def test_str_pubkey_too_short_stays_none(self, mock_logger):
+ """A one-character str pubkey does not satisfy len >= 2; prefix stays None."""
+ bot = _make_bot_with_device(mock_logger, pubkey="a")
+ tracker = TransmissionTracker(bot)
+ assert tracker.bot_prefix is None
+
+ def test_no_meshcore_leaves_prefix_none(self, mock_logger):
+ """When bot.meshcore is None the prefix is never set."""
+ bot = Mock()
+ bot.logger = mock_logger
+ bot.meshcore = None
+ bot.prefix_hex_chars = 2
+ tracker = TransmissionTracker(bot)
+ assert tracker.bot_prefix is None
+
+ def test_exception_during_prefix_update_leaves_prefix_none(self, mock_logger):
+ """If accessing device.public_key raises an exception bot_prefix remains None."""
+ device = Mock()
+ # Make accessing public_key raise an exception.
+ type(device).public_key = property(lambda s: (_ for _ in ()).throw(RuntimeError("boom")))
+ meshcore = Mock()
+ meshcore.device = device
+ bot = Mock()
+ bot.logger = mock_logger
+ bot.meshcore = meshcore
+ bot.prefix_hex_chars = 2
+ tracker = TransmissionTracker(bot)
+ assert tracker.bot_prefix is None
+
+
+# ---------------------------------------------------------------------------
+# Tests for _update_command_in_database (lines 190, 198-246)
+# ---------------------------------------------------------------------------
+
+def _build_tracker_with_db(mock_logger, tmp_path):
+ """Build a tracker whose bot has a real SQLite DB at tmp_path/test.db."""
+ db_path = str(tmp_path / "test.db")
+ _make_db_with_packet_stream(db_path)
+
+ config = Mock()
+ config.has_section = Mock(return_value=False)
+ config.has_option = Mock(return_value=False)
+ config.get = Mock(return_value="")
+
+ db_manager = Mock()
+ db_manager.db_path = db_path
+
+ bot = Mock()
+ bot.logger = mock_logger
+ bot.meshcore = None
+ bot.prefix_hex_chars = 2
+ bot.config = config
+ bot.bot_root = str(tmp_path)
+ bot.db_manager = db_manager
+ bot.web_viewer_integration = Mock() # truthy so the DB path is reached
+
+ return TransmissionTracker(bot), db_path
+
+
+class TestUpdateCommandInDatabase:
+ """Cover lines 190 and 198-246: _update_command_in_database."""
+
+ def test_early_return_when_command_id_is_none(self, mock_logger, tmp_path):
+ """Line 190: method returns immediately when record.command_id is None."""
+ tracker, db_path = _build_tracker_with_db(mock_logger, tmp_path)
+ rec = TransmissionRecord(
+ timestamp=time.time(),
+ content="msg",
+ target="ch",
+ message_type="channel",
+ command_id=None,
+ )
+ # No exception and the DB is untouched (table stays empty).
+ tracker._update_command_in_database(rec)
+ with closing(sqlite3.connect(db_path)) as conn:
+ count = conn.execute("SELECT COUNT(*) FROM packet_stream").fetchone()[0]
+ assert count == 0
+
+ def test_updates_matching_row_in_database(self, mock_logger, tmp_path):
+ """Lines 198-246: a matching command row is found and updated."""
+ tracker, db_path = _build_tracker_with_db(mock_logger, tmp_path)
+
+ command_id = "cmd-update-test"
+ initial_data = {
+ "command_id": command_id,
+ "repeat_count": 0,
+ "repeater_prefixes": [],
+ "repeater_counts": {},
+ }
+
+ # Insert a row into packet_stream.
+ with closing(sqlite3.connect(db_path)) as conn:
+ conn.execute(
+ "INSERT INTO packet_stream (type, timestamp, data) VALUES (?, ?, ?)",
+ ("command", time.time(), json.dumps(initial_data)),
+ )
+ conn.commit()
+
+ # Build a record whose command_id matches.
+ rec = TransmissionRecord(
+ timestamp=time.time(),
+ content="hello",
+ target="general",
+ message_type="channel",
+ command_id=command_id,
+ repeat_count=3,
+ )
+ rec.repeater_prefixes = {"7e", "ab"}
+ rec.repeater_counts = {"7e": 2, "ab": 1}
+
+ tracker._update_command_in_database(rec)
+
+ # Verify the row was updated.
+ with closing(sqlite3.connect(db_path)) as conn:
+ row = conn.execute(
+ "SELECT data FROM packet_stream WHERE type = 'command'"
+ ).fetchone()
+
+ assert row is not None
+ updated = json.loads(row[0])
+ assert updated["repeat_count"] == 3
+ assert sorted(updated["repeater_prefixes"]) == ["7e", "ab"] or set(updated["repeater_prefixes"]) == {"7e", "ab"}
+ assert updated["repeater_counts"]["7e"] == 2
+ assert updated["repeater_counts"]["ab"] == 1
+
+ def test_no_matching_row_leaves_db_unchanged(self, mock_logger, tmp_path):
+ """If no row matches command_id, the DB is not modified."""
+ tracker, db_path = _build_tracker_with_db(mock_logger, tmp_path)
+
+ other_data = {"command_id": "other-cmd", "repeat_count": 0}
+ with closing(sqlite3.connect(db_path)) as conn:
+ conn.execute(
+ "INSERT INTO packet_stream (type, timestamp, data) VALUES (?, ?, ?)",
+ ("command", time.time(), json.dumps(other_data)),
+ )
+ conn.commit()
+
+ rec = TransmissionRecord(
+ timestamp=time.time(),
+ content="msg",
+ target="ch",
+ message_type="channel",
+ command_id="no-match-cmd",
+ repeat_count=1,
+ )
+ tracker._update_command_in_database(rec)
+
+ with closing(sqlite3.connect(db_path)) as conn:
+ row = conn.execute(
+ "SELECT data FROM packet_stream WHERE type = 'command'"
+ ).fetchone()
+ assert json.loads(row[0])["command_id"] == "other-cmd"
+
+ def test_malformed_json_row_is_skipped(self, mock_logger, tmp_path):
+ """A row with invalid JSON is silently skipped (json.JSONDecodeError path)."""
+ tracker, db_path = _build_tracker_with_db(mock_logger, tmp_path)
+
+ with closing(sqlite3.connect(db_path)) as conn:
+ conn.execute(
+ "INSERT INTO packet_stream (type, timestamp, data) VALUES (?, ?, ?)",
+ ("command", time.time(), "NOT VALID JSON"),
+ )
+ conn.commit()
+
+ rec = TransmissionRecord(
+ timestamp=time.time(),
+ content="msg",
+ target="ch",
+ message_type="channel",
+ command_id="cmd-x",
+ repeat_count=1,
+ )
+ # Should not raise even though JSON is malformed.
+ tracker._update_command_in_database(rec)
+
+
+# ---------------------------------------------------------------------------
+# Tests for line 314: path with parenthesis hop-count annotation
+# ---------------------------------------------------------------------------
+
+class TestExtractRepeaterPrefixesParenPath:
+ """Cover line 314: path containing '(' (hop-count annotation) is stripped."""
+
+ def test_path_with_paren_stripped_before_split(self, mock_logger):
+ """'01,7e,86(3)' should extract '86' after stripping the parenthesised part."""
+ bot = Mock()
+ bot.logger = mock_logger
+ bot.meshcore = None
+ bot.prefix_hex_chars = 2
+ tracker = TransmissionTracker(bot)
+ tracker.bot_prefix = None
+
+ result = tracker.extract_repeater_prefixes_from_path("01,7e,86(3)")
+ assert result == ["86"]
+
+ def test_path_with_paren_and_via(self, mock_logger):
+ """Combined annotation: ' via ROUTE_TYPE_*' and '(' in the path part."""
+ bot = Mock()
+ bot.logger = mock_logger
+ bot.meshcore = None
+ bot.prefix_hex_chars = 2
+ tracker = TransmissionTracker(bot)
+ tracker.bot_prefix = None
+
+ result = tracker.extract_repeater_prefixes_from_path("01,7e,ab(2) via ROUTE_TYPE_FLOOD")
+ assert result == ["ab"]
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 3a509e4..d247049 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -635,3 +635,54 @@ class TestMultiBytePathDisplayContract:
display = ",".join(n.lower() for n in nodes)
assert display == "01,02,5f,ab"
+
+class TestCalculatePacketHashEdgeCases:
+ """Additional calculate_packet_hash branch coverage."""
+
+ def test_payload_type_with_value_attr_handled(self):
+ """Lines 376-378: payload_type object with .value attribute is accepted."""
+ # FLOOD+TXT_MSG header: (0<<6)|(2<<2)|1 = 0x09, no transport, 0 hops, 1 payload byte
+ raw = "090000ff"
+
+ class FakeEnum:
+ value = 2 # TXT_MSG
+
+ h = calculate_packet_hash(raw, payload_type=FakeEnum())
+ assert h != "0000000000000000"
+ assert len(h) == 16
+
+ def test_too_short_for_path_len_returns_default(self):
+ """Line 391: packet with only header byte (and transport if applicable) → default hash."""
+ # Header only, no path_len_byte: just "09" (1 byte, offset=1, len<=1 → too short)
+ h = calculate_packet_hash("09")
+ assert h == "0000000000000000"
+
+ def test_not_enough_path_bytes_returns_default(self):
+ """Line 399: path_len_byte says N hops but fewer bytes available → default hash."""
+ # Header 0x09 (FLOOD+TXT_MSG), path_len_byte=0x02 (2 hops 1-byte), but only 1 path byte
+ h = calculate_packet_hash("09020100") # header, path_len(2 hops), 1 path byte + 1 'payload'?
+ # Actually: header(09), path_len(02)=2 hops → needs 2 path bytes + 1 payload byte = 5 bytes total
+ # We provide 4 bytes: 09 02 01 00 → path bytes = 01 00 but payload missing?
+ # Let's check: offset=1, path_len_byte=02 → path_byte_length=2, offset after path_len=2
+ # need len>=2+2=4 for path, but payload needed too: len>=5
+ # Exactly 4 bytes → payload_start=4 → len==4 not > 4 → return default
+ assert h == "0000000000000000"
+
+ def test_exception_in_packet_hash_returns_default(self):
+ """Lines 427-429: exception during processing returns default hash."""
+ h = calculate_packet_hash("not-valid-hex!!!")
+ assert h == "0000000000000000"
+
+ def test_non_transport_type_skips_transport_bytes(self):
+ """Route type 1 (FLOOD) → no transport bytes; hash succeeds."""
+ # Header 0x09 = FLOOD + TXT_MSG, path_len=0, payload=0xFF
+ h = calculate_packet_hash("090000ff")
+ assert h != "0000000000000000"
+ assert len(h) == 16
+
+ def test_transport_type_skips_4_bytes(self):
+ """Route type 0 (TRANSPORT_FLOOD) → offset +=4; packet big enough."""
+ # Header 0x08 = TRANSPORT_FLOOD + TXT_MSG, 4 transport bytes, path_len=0, payload=0xFF
+ h = calculate_packet_hash("0800000000" + "00" + "ff")
+ assert h != "0000000000000000"
+
diff --git a/tests/test_web_viewer.py b/tests/test_web_viewer.py
index e821075..ea29af1 100644
--- a/tests/test_web_viewer.py
+++ b/tests/test_web_viewer.py
@@ -143,10 +143,53 @@ class TestPageRoutes:
resp = client.get("/")
assert resp.status_code == 200
+ def test_index_live_activity_controls(self, client):
+ """Dashboard index page contains scroll buttons and type-filter checkboxes."""
+ resp = client.get("/")
+ assert resp.status_code == 200
+ html = resp.data.decode()
+ # Scroll buttons
+ assert 'id="live-scroll-top"' in html
+ assert 'id="live-scroll-bottom"' in html
+ assert 'scrollLiveFeed' in html
+ # Filter checkboxes with data-type attributes
+ assert 'data-type="packet"' in html
+ assert 'data-type="command"' in html
+ assert 'data-type="message"' in html
+ assert 'live-filter-cb' in html
+ # [#channel] prefix logic present in JS
+ assert 'applyFilters' in html
+
def test_realtime(self, client):
resp = client.get("/realtime")
assert resp.status_code == 200
+ def test_realtime_scroll_controls(self, client):
+ """Realtime page has scroll buttons, type filters, and channel labels in messages."""
+ resp = client.get("/realtime")
+ assert resp.status_code == 200
+ html = resp.data.decode()
+ # Scroll buttons present for all three streams
+ assert 'id="cmd-scroll-top"' in html
+ assert 'id="cmd-scroll-bottom"' in html
+ assert 'id="pkt-scroll-top"' in html
+ assert 'id="pkt-scroll-bottom"' in html
+ assert 'id="msg-scroll-top"' in html
+ assert 'id="msg-scroll-bottom"' in html
+ # scrollStream JS function present
+ assert 'scrollStream' in html
+ # Type filter checkboxes for each stream panel
+ assert 'rt-filter-cb' in html
+ assert 'id="rt-filter-command"' in html
+ assert 'id="rt-filter-packet"' in html
+ assert 'id="rt-filter-message"' in html
+ assert 'id="command-card"' in html
+ assert 'id="packet-card"' in html
+ assert 'id="message-card"' in html
+ # Channel label placeholder in message entry template
+ assert 'channelLabel' in html
+ assert '[#' in html
+
def test_logs(self, client):
resp = client.get("/logs")
assert resp.status_code == 200
@@ -922,7 +965,7 @@ class TestConfigMaintenanceRoutes:
"db_backup_schedule": "weekly",
"db_backup_time": "03:00",
"db_backup_retention_count": "14",
- "db_backup_dir": "/tmp/backups",
+ "db_backup_dir": "/tmp", # /tmp always exists
"email_attach_log": "true",
}
resp = client.post(
@@ -1619,3 +1662,1167 @@ class TestSubscribeCommandsHistoryReplay:
assert "time() - 300" in src or "_time.time() - 300" in src, (
"last_timestamp must be initialized to time.time()-300, not 0"
)
+
+
+# ---------------------------------------------------------------------------
+# TASK-03: GET /api/connected_clients
+# ---------------------------------------------------------------------------
+
+class TestConnectedClientsApi:
+ """Tests for GET /api/connected_clients endpoint."""
+
+ def test_empty_when_no_clients(self, viewer):
+ """Returns empty list when no clients connected."""
+ viewer.connected_clients.clear()
+ with viewer.app.test_client() as c:
+ resp = c.get("/api/connected_clients")
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert data == []
+
+ def test_returns_client_list(self, viewer):
+ """Returns list with client_id, connected_at, last_activity fields."""
+ import time
+ now = time.time()
+ viewer.connected_clients["abcdef1234567890"] = {
+ "connected_at": now - 60,
+ "last_activity": now - 5,
+ "subscribed_commands": False,
+ }
+ try:
+ with viewer.app.test_client() as c:
+ resp = c.get("/api/connected_clients")
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert isinstance(data, list)
+ assert len(data) == 1
+ entry = data[0]
+ assert "client_id" in entry
+ assert "connected_at" in entry
+ assert "last_activity" in entry
+ # ID is truncated to first 8 chars + ellipsis
+ assert entry["client_id"] == "abcdef12\u2026"
+ assert abs(entry["connected_at"] - (now - 60)) < 1
+ assert abs(entry["last_activity"] - (now - 5)) < 1
+ finally:
+ viewer.connected_clients.pop("abcdef1234567890", None)
+
+ def test_multiple_clients(self, viewer):
+ """Returns all connected clients."""
+ import time
+ now = time.time()
+ viewer.connected_clients["aaa"] = {"connected_at": now, "last_activity": now}
+ viewer.connected_clients["bbb"] = {"connected_at": now, "last_activity": now}
+ try:
+ with viewer.app.test_client() as c:
+ resp = c.get("/api/connected_clients")
+ assert resp.status_code == 200
+ data = resp.get_json()
+ ids = [entry["client_id"] for entry in data]
+ assert "aaa" in ids
+ assert "bbb" in ids
+ finally:
+ viewer.connected_clients.pop("aaa", None)
+ viewer.connected_clients.pop("bbb", None)
+
+ def test_short_id_not_truncated(self, viewer):
+ """Client IDs of 8 chars or fewer are returned as-is."""
+ import time
+ now = time.time()
+ viewer.connected_clients["short"] = {"connected_at": now, "last_activity": now}
+ try:
+ with viewer.app.test_client() as c:
+ resp = c.get("/api/connected_clients")
+ assert resp.status_code == 200
+ data = resp.get_json()
+ match = [entry for entry in data if entry["client_id"] == "short"]
+ assert len(match) == 1
+ finally:
+ viewer.connected_clients.pop("short", None)
+
+ def test_dashboard_contains_modal_and_link(self, viewer):
+ """Dashboard page includes the connected-clients modal and clickable link."""
+ with viewer.app.test_client() as c:
+ resp = c.get("/")
+ html = resp.data.decode()
+ assert "connectedClientsModal" in html
+ assert "connected-clients-table" in html
+ assert "loadConnectedClients" in html
+
+
+# ---------------------------------------------------------------------------
+# TASK-04: DB backup dir validation on POST /api/config/maintenance
+# ---------------------------------------------------------------------------
+
+class TestDbBackupDirValidation:
+ """Tests for backup directory validation in POST /api/config/maintenance."""
+
+ def test_nonexistent_dir_returns_400(self, viewer):
+ """Returns 400 with error message when backup_dir does not exist."""
+ with viewer.app.test_client() as c:
+ resp = c.post(
+ "/api/config/maintenance",
+ json={"db_backup_dir": "/nonexistent/path/that/does/not/exist"},
+ content_type="application/json",
+ )
+ assert resp.status_code == 400
+ data = resp.get_json()
+ assert "error" in data
+ assert "/nonexistent/path/that/does/not/exist" in data["error"]
+
+ def test_existing_dir_returns_200(self, viewer, tmp_path):
+ """Returns 200 when backup_dir exists."""
+ with viewer.app.test_client() as c:
+ resp = c.post(
+ "/api/config/maintenance",
+ json={"db_backup_dir": str(tmp_path)},
+ content_type="application/json",
+ )
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert data["success"] is True
+ assert "db_backup_dir" in data["saved"]
+
+ def test_empty_dir_skips_validation(self, viewer):
+ """Empty string for db_backup_dir skips the isdir check and saves."""
+ with viewer.app.test_client() as c:
+ resp = c.post(
+ "/api/config/maintenance",
+ json={"db_backup_dir": ""},
+ content_type="application/json",
+ )
+ assert resp.status_code == 200
+
+ def test_other_fields_save_when_no_dir(self, viewer):
+ """Other maintenance fields save normally when db_backup_dir is absent."""
+ with viewer.app.test_client() as c:
+ resp = c.post(
+ "/api/config/maintenance",
+ json={"db_backup_enabled": "true", "db_backup_schedule": "daily"},
+ content_type="application/json",
+ )
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert "db_backup_enabled" in data["saved"]
+ assert "db_backup_schedule" in data["saved"]
+
+ def test_error_message_contains_path(self, viewer):
+ """Error message specifically mentions the invalid path."""
+ bad_path = "/absolutely/does/not/exist/xyz"
+ with viewer.app.test_client() as c:
+ resp = c.post(
+ "/api/config/maintenance",
+ json={"db_backup_dir": bad_path},
+ content_type="application/json",
+ )
+ assert resp.status_code == 400
+ assert bad_path in resp.get_json()["error"]
+
+
+# ---------------------------------------------------------------------------
+# TASK-06: POST /api/maintenance/backup_now
+# ---------------------------------------------------------------------------
+
+class TestBackupNowRoute:
+ """Tests for POST /api/maintenance/backup_now endpoint."""
+
+ def test_returns_503_when_no_scheduler(self, viewer):
+ """Returns 503 when bot/scheduler is not attached."""
+ # viewer fixture has no bot attached
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/backup_now")
+ assert resp.status_code == 503
+ data = resp.get_json()
+ assert data["success"] is False
+ assert "Scheduler not available" in data["error"]
+
+ def test_returns_200_on_successful_backup(self, viewer, tmp_path):
+ """Returns 200 with success=True and path when backup succeeds."""
+ from unittest.mock import MagicMock, patch
+
+ mock_scheduler = MagicMock()
+
+ def fake_run_db_backup():
+ # Simulate what _run_db_backup writes to metadata
+ viewer.db_manager.set_metadata(
+ 'maint.status.db_backup_path', str(tmp_path / "test.db")
+ )
+ viewer.db_manager.set_metadata('maint.status.db_backup_outcome', 'ok')
+
+ mock_scheduler._run_db_backup = fake_run_db_backup
+ mock_bot = MagicMock()
+ mock_bot.scheduler = mock_scheduler
+
+ with patch.object(viewer, 'bot', mock_bot, create=True):
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/backup_now")
+
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert data["success"] is True
+ assert "test.db" in data["path"]
+
+ def test_returns_500_on_backup_error(self, viewer):
+ """Returns 500 with success=False when backup writes an error outcome."""
+ from unittest.mock import MagicMock, patch
+
+ mock_scheduler = MagicMock()
+
+ def fake_run_db_backup():
+ viewer.db_manager.set_metadata('maint.status.db_backup_path', '')
+ viewer.db_manager.set_metadata(
+ 'maint.status.db_backup_outcome', 'error: cannot create dir'
+ )
+
+ mock_scheduler._run_db_backup = fake_run_db_backup
+ mock_bot = MagicMock()
+ mock_bot.scheduler = mock_scheduler
+
+ with patch.object(viewer, 'bot', mock_bot, create=True):
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/backup_now")
+
+ assert resp.status_code == 500
+ data = resp.get_json()
+ assert data["success"] is False
+
+ def test_config_page_contains_backup_now_button(self, viewer):
+ """Config page HTML includes the Backup Now button."""
+ with viewer.app.test_client() as c:
+ resp = c.get("/config")
+ html = resp.data.decode()
+ assert "backup-now-btn" in html
+ assert "backup_now" in html
+
+
+# ---------------------------------------------------------------------------
+# TASK-07: POST /api/maintenance/restore + GET /api/maintenance/list_backups
+# ---------------------------------------------------------------------------
+
+class TestRestoreRoute:
+ """Tests for the DB restore endpoint."""
+
+ def test_missing_db_file_returns_400(self, viewer):
+ """Returns 400 when db_file is absent."""
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/restore", json={},
+ content_type="application/json")
+ assert resp.status_code == 400
+ assert "db_file" in resp.get_json()["error"]
+
+ def test_nonexistent_file_returns_400(self, viewer):
+ """Returns 400 when db_file path does not exist."""
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/restore",
+ json={"db_file": "/no/such/file.db"},
+ content_type="application/json")
+ assert resp.status_code == 400
+ assert "not found" in resp.get_json()["error"].lower()
+
+ def test_non_sqlite_file_returns_400(self, viewer, tmp_path):
+ """Returns 400 when the file is not a valid SQLite database."""
+ bad = tmp_path / "bad.db"
+ bad.write_bytes(b"not a sqlite file!!")
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/restore",
+ json={"db_file": str(bad)},
+ content_type="application/json")
+ assert resp.status_code == 400
+ assert "valid SQLite" in resp.get_json()["error"]
+
+ def test_valid_sqlite_restore_returns_200(self, viewer, tmp_path):
+ """Returns 200 with warning when a valid SQLite backup is restored."""
+ import sqlite3 as _sql
+ backup = tmp_path / "backup.db"
+ conn = _sql.connect(str(backup))
+ conn.execute("CREATE TABLE t (id INTEGER)")
+ conn.commit()
+ conn.close()
+ # Patch db_path to a temp destination so the real test DB is not overwritten
+ dest = str(tmp_path / "restored.db")
+ with patch.object(viewer, "db_path", dest):
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/restore",
+ json={"db_file": str(backup)},
+ content_type="application/json")
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert data["success"] is True
+ assert "warning" in data
+ assert "Restart" in data["warning"]
+
+ def test_list_backups_empty_when_no_dir(self, viewer):
+ """list_backups returns empty list when backup dir not configured."""
+ viewer.db_manager.set_metadata('maint.db_backup_dir', '')
+ with viewer.app.test_client() as c:
+ resp = c.get("/api/maintenance/list_backups")
+ assert resp.status_code == 200
+ assert resp.get_json()["backups"] == []
+
+ def test_list_backups_returns_files(self, viewer, tmp_path):
+ """list_backups returns matching .db files from the backup directory."""
+ import sqlite3 as _sql
+ db_stem = Path(viewer.db_path).stem
+ for i in range(2):
+ f = tmp_path / f"{db_stem}_2026010{i}T000000.db"
+ conn = _sql.connect(str(f))
+ conn.execute("CREATE TABLE t (id INTEGER)")
+ conn.commit()
+ conn.close()
+ viewer.db_manager.set_metadata('maint.db_backup_dir', str(tmp_path))
+ with viewer.app.test_client() as c:
+ resp = c.get("/api/maintenance/list_backups")
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert len(data["backups"]) == 2
+ assert all("path" in b and "size_mb" in b for b in data["backups"])
+
+ def test_config_page_contains_restore_modal(self, viewer):
+ """Config page HTML includes the restore modal and button."""
+ with viewer.app.test_client() as c:
+ resp = c.get("/config")
+ html = resp.data.decode()
+ assert "restoreModal" in html
+ assert "restore-btn" in html
+ assert "restore-db-path" in html
+
+
+# ---------------------------------------------------------------------------
+# TASK-08: Database purge by age
+# ---------------------------------------------------------------------------
+
+class TestPurgeRoute:
+ """Tests for POST /api/maintenance/purge."""
+
+ def test_keep_all_returns_empty_deleted(self, viewer):
+ """keep_days='all' returns 200 with empty deleted dict."""
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/purge",
+ json={"keep_days": "all"},
+ content_type="application/json")
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert data["deleted"] == {}
+
+ def test_invalid_keep_days_returns_400(self, viewer):
+ """keep_days=3 (not in valid set) returns 400."""
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/purge",
+ json={"keep_days": 3},
+ content_type="application/json")
+ assert resp.status_code == 400
+ assert "keep_days" in resp.get_json()["error"]
+
+ def test_non_integer_keep_days_returns_400(self, viewer):
+ """keep_days='invalid' returns 400."""
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/purge",
+ json={"keep_days": "invalid"},
+ content_type="application/json")
+ assert resp.status_code == 400
+
+ def test_valid_keep_days_returns_deleted_counts(self, viewer):
+ """keep_days=30 returns 200 with per-table deleted counts."""
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/purge",
+ json={"keep_days": 30},
+ content_type="application/json")
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert "deleted" in data
+ # All six purgeable tables should be present in the response
+ expected_tables = {
+ "packet_stream", "message_stats", "complete_contact_tracking",
+ "purging_log", "mesh_connections", "daily_stats",
+ }
+ assert expected_tables == set(data["deleted"].keys())
+
+ def test_all_valid_keep_days_values_accepted(self, viewer):
+ """All documented valid keep_days values (1,7,14,30,60,90) return 200."""
+ for days in [1, 7, 14, 30, 60, 90]:
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/purge",
+ json={"keep_days": days},
+ content_type="application/json")
+ assert resp.status_code == 200, f"Failed for keep_days={days}"
+
+ def test_old_rows_deleted_recent_rows_kept(self, viewer):
+ """Rows older than cutoff are deleted; recent rows are kept."""
+ import sqlite3 as _sql
+ import time as _time
+
+ old_ts = _time.time() - 40 * 86400 # 40 days ago
+ new_ts = _time.time() - 1 * 86400 # 1 day ago
+
+ # Insert one old and one new row into packet_stream
+ with _sql.connect(viewer.db_path) as conn:
+ conn.execute(
+ "INSERT INTO packet_stream (timestamp, data, type) VALUES (?, ?, ?)",
+ (old_ts, '{"test": "old"}', "test"),
+ )
+ conn.execute(
+ "INSERT INTO packet_stream (timestamp, data, type) VALUES (?, ?, ?)",
+ (new_ts, '{"test": "new"}', "test"),
+ )
+ conn.commit()
+
+ with viewer.app.test_client() as c:
+ resp = c.post("/api/maintenance/purge",
+ json={"keep_days": 30},
+ content_type="application/json")
+ assert resp.status_code == 200
+ data = resp.get_json()
+ # At least the one old packet_stream row should be deleted
+ assert data["deleted"]["packet_stream"] >= 1
+
+ # Recent row must still be present
+ with _sql.connect(viewer.db_path) as conn:
+ row = conn.execute(
+ "SELECT COUNT(*) FROM packet_stream WHERE data = ?",
+ ('{"test": "new"}',),
+ ).fetchone()
+ assert row[0] == 1
+
+ def test_config_page_contains_purge_card(self, viewer):
+ """Config page HTML includes the purge card and confirmation modal."""
+ with viewer.app.test_client() as c:
+ resp = c.get("/config")
+ html = resp.data.decode()
+ assert "purgeModal" in html
+ assert "purge-keep-days" in html
+ assert "purge-confirm-btn" in html
+
+
+# ---------------------------------------------------------------------------
+# TASK-09: BotIntegration write queue (batch packet_stream inserts)
+# ---------------------------------------------------------------------------
+
+class TestBotIntegrationQueue:
+ """Tests for BotIntegration's batched packet_stream write queue."""
+
+ @pytest.fixture()
+ def bot_integration(self, tmp_path):
+ """Create a BotIntegration with a real temp SQLite DB and a fake bot."""
+ import configparser as _cp
+ from unittest.mock import MagicMock
+
+ db_path = str(tmp_path / "test.db")
+ cfg = _cp.ConfigParser()
+ cfg["Connection"] = {"connection_type": "serial", "serial_port": "/dev/null"}
+ cfg["Bot"] = {"bot_name": "Test", "db_path": db_path, "prefix_bytes": "1"}
+
+ bot = MagicMock()
+ bot.logger = logging.getLogger("test_integration")
+ bot.logger.addHandler(logging.NullHandler())
+ bot.config = cfg
+ bot.db_manager.db_path = db_path
+ bot.bot_root = str(tmp_path)
+
+ from modules.web_viewer.integration import BotIntegration
+ bi = BotIntegration(bot)
+ yield bi
+ bi.shutdown()
+
+ def test_insert_queues_row_without_db_open(self, bot_integration):
+ """_insert_packet_stream_row puts item in queue, does not open DB immediately."""
+ # Queue should start empty
+ assert bot_integration._write_queue.empty()
+ bot_integration._insert_packet_stream_row('{"x":1}', 'packet')
+ assert bot_integration._write_queue.qsize() == 1
+
+ def test_flush_writes_row_to_db(self, bot_integration, tmp_path):
+ """_flush_write_queue inserts queued rows into packet_stream."""
+ bot_integration._insert_packet_stream_row('{"test":"flush"}', 'packet')
+ bot_integration._flush_write_queue()
+
+ db_path = bot_integration._get_web_viewer_db_path()
+ with sqlite3.connect(db_path) as conn:
+ rows = conn.execute(
+ "SELECT data, type FROM packet_stream WHERE type='packet'"
+ ).fetchall()
+ assert any(r[0] == '{"test":"flush"}' for r in rows)
+
+ def test_flush_batches_multiple_rows(self, bot_integration):
+ """_flush_write_queue inserts multiple queued rows in one transaction."""
+ for i in range(5):
+ bot_integration._insert_packet_stream_row(f'{{"n":{i}}}', 'command')
+ assert bot_integration._write_queue.qsize() == 5
+ bot_integration._flush_write_queue()
+ assert bot_integration._write_queue.empty()
+
+ db_path = bot_integration._get_web_viewer_db_path()
+ with sqlite3.connect(db_path) as conn:
+ count = conn.execute(
+ "SELECT COUNT(*) FROM packet_stream WHERE type='command'"
+ ).fetchone()[0]
+ assert count == 5
+
+ def test_flush_empty_queue_is_noop(self, bot_integration):
+ """_flush_write_queue on empty queue does not raise."""
+ assert bot_integration._write_queue.empty()
+ bot_integration._flush_write_queue() # should not raise
+
+ def test_shutdown_flushes_remaining_rows(self, bot_integration):
+ """shutdown() flushes rows that were queued but not yet drained."""
+ # Stop the drain thread early so rows stay in queue
+ bot_integration._drain_stop.set()
+ if bot_integration._drain_thread:
+ bot_integration._drain_thread.join(timeout=2.0)
+
+ bot_integration._insert_packet_stream_row('{"shutdown":"test"}', 'message')
+ assert not bot_integration._write_queue.empty()
+
+ # shutdown() must flush the remaining row
+ bot_integration.shutdown()
+
+ db_path = bot_integration._get_web_viewer_db_path()
+ with sqlite3.connect(db_path) as conn:
+ rows = conn.execute(
+ "SELECT data FROM packet_stream WHERE type='message'"
+ ).fetchall()
+ assert any(r[0] == '{"shutdown":"test"}' for r in rows)
+
+ def test_drain_thread_is_running(self, bot_integration):
+ """Drain thread is alive after construction."""
+ assert bot_integration._drain_thread is not None
+ assert bot_integration._drain_thread.is_alive()
+
+
+# ===========================================================================
+# PUT /api/channels/