test: coverage expansion — commands, web viewer, and infrastructure

New test modules:
- test_announcements_command: parse, record_trigger, execute paths
- test_aurora_command: KP index parsing, alert levels, execute paths
- test_channel_manager: generate_hashtag_key, cache lookups, validation
- test_channels_command: remaining channel info display paths
- test_dadjoke_command: format, split, length, execute
- test_graph_trace_helper: geo-location helper and graph algorithm paths
- test_hacker_command: text transform logic
- test_help_command: format list, channel filter, general/specific help
- test_i18n: fallback loops, format failure, PermissionError, get_value
- test_joke_command: seasonal, format, split, dark, execute
- test_moon_command: phase calc, execute success/error
- test_multitest_command: multi-channel test sequences
- test_stats_command: adverts leaderboard, get_stats_summary, cleanup
- test_trace_command: path extract, parse, format inline/vertical
- test_web_viewer_integration: circuit breaker, JSON serializer,
  packet capture, channel message
- test_webviewer_command: 100% coverage

Extended existing: test_command_manager, test_feed_manager,
test_message_handler, test_rate_limiter, test_repeater_manager,
test_scheduler_logic, test_security_utils, test_transmission_tracker,
test_utils, test_web_viewer
This commit is contained in:
Stacy Olivas
2026-03-17 18:07:19 -07:00
parent ad77d7b00d
commit 9be5166485
26 changed files with 10633 additions and 16 deletions
+376
View File
@@ -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
+403
View File
@@ -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 + "..."
+377
View File
@@ -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
+284
View File
@@ -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)
+217
View File
@@ -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
+227
View File
@@ -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
+506 -1
View File
@@ -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="<p>Hello <b>world</b></p>")
result = self.fm.format_message(item, self._feed(fmt="{body}"))
assert "<p>" not in result
assert "Hello" in result
def test_body_br_to_newline(self):
item = self._item(description="Line1<br>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)
+320
View File
@@ -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
+360
View File
@@ -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
+462
View File
@@ -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)
+203
View File
@@ -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"
+321
View File
@@ -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
+732
View File
@@ -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
+210
View File
@@ -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()
+156
View File
@@ -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
+323 -1
View File
@@ -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
+745 -1
View File
@@ -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}"
+798 -12
View File
@@ -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 152293)."""
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 308328)."""
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 121150)."""
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 501581)."""
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 583607)."""
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 9271014)."""
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
+118
View File
@@ -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))
+873
View File
@@ -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 == {}
+381
View File
@@ -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
+273
View File
@@ -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"]
+51
View File
@@ -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"
+1208 -1
View File
File diff suppressed because it is too large Load Diff
+438
View File
@@ -0,0 +1,438 @@
"""Tests for modules.web_viewer.integration — BotIntegration pure logic."""
import json
import queue
import time
from configparser import ConfigParser
from contextlib import suppress
from unittest.mock import MagicMock, Mock, patch
import pytest
# ---------------------------------------------------------------------------
# Factory helpers
# ---------------------------------------------------------------------------
def _make_bot():
"""Create minimal mock bot for BotIntegration tests."""
bot = MagicMock()
bot.logger = Mock()
config = ConfigParser()
config.add_section("Bot")
config.add_section("Web_Viewer")
config.set("Web_Viewer", "host", "127.0.0.1")
config.set("Web_Viewer", "port", "8080")
config.set("Web_Viewer", "enabled", "false")
config.set("Web_Viewer", "auto_start", "false")
config.set("Web_Viewer", "debug", "false")
bot.config = config
bot.bot_root = "/tmp"
bot.db_manager = MagicMock()
bot.db_manager.db_path = ":memory:"
bot.transmission_tracker = None
return bot
def _make_bot_integration(bot=None):
"""Create BotIntegration with all I/O patched out."""
if bot is None:
bot = _make_bot()
from modules.web_viewer.integration import BotIntegration
with patch.object(BotIntegration, "_init_http_session"), \
patch.object(BotIntegration, "_init_packet_stream_table"), \
patch.object(BotIntegration, "_start_drain_thread"):
obj = BotIntegration(bot)
# Give it a real write queue for testing
obj._write_queue = queue.Queue()
obj.http_session = None
return obj
# ---------------------------------------------------------------------------
# reset_circuit_breaker
# ---------------------------------------------------------------------------
class TestResetCircuitBreaker:
def test_clears_open_flag(self):
bi = _make_bot_integration()
bi.circuit_breaker_open = True
bi.circuit_breaker_failures = 5
bi.reset_circuit_breaker()
assert bi.circuit_breaker_open is False
assert bi.circuit_breaker_failures == 0
# ---------------------------------------------------------------------------
# _should_skip_web_viewer_send
# ---------------------------------------------------------------------------
class TestShouldSkipWebViewerSend:
def test_not_open_returns_false(self):
bi = _make_bot_integration()
bi.circuit_breaker_open = False
assert bi._should_skip_web_viewer_send() is False
def test_open_within_cooldown_returns_true(self):
bi = _make_bot_integration()
bi.circuit_breaker_open = True
bi.circuit_breaker_last_failure_time = time.time() # just now
assert bi._should_skip_web_viewer_send() is True
def test_open_beyond_cooldown_resets_and_returns_false(self):
bi = _make_bot_integration()
bi.circuit_breaker_open = True
bi.circuit_breaker_failures = 3
# Set last failure time far in the past
bi.circuit_breaker_last_failure_time = time.time() - bi.CIRCUIT_BREAKER_COOLDOWN_SEC - 1
assert bi._should_skip_web_viewer_send() is False
assert bi.circuit_breaker_open is False
# ---------------------------------------------------------------------------
# _record_web_viewer_result
# ---------------------------------------------------------------------------
class TestRecordWebViewerResult:
def test_success_resets_circuit(self):
bi = _make_bot_integration()
bi.circuit_breaker_failures = 2
bi.circuit_breaker_open = True
bi._record_web_viewer_result(True)
assert bi.circuit_breaker_failures == 0
assert bi.circuit_breaker_open is False
def test_failure_increments_counter(self):
bi = _make_bot_integration()
bi._record_web_viewer_result(False)
assert bi.circuit_breaker_failures == 1
def test_failure_opens_circuit_at_threshold(self):
bi = _make_bot_integration()
for _ in range(bi.CIRCUIT_BREAKER_THRESHOLD):
bi._record_web_viewer_result(False)
assert bi.circuit_breaker_open is True
def test_failure_below_threshold_does_not_open(self):
bi = _make_bot_integration()
for _ in range(bi.CIRCUIT_BREAKER_THRESHOLD - 1):
bi._record_web_viewer_result(False)
assert bi.circuit_breaker_open is False
# ---------------------------------------------------------------------------
# _make_json_serializable
# ---------------------------------------------------------------------------
class TestMakeJsonSerializable:
def setup_method(self):
self.bi = _make_bot_integration()
def test_none_passes_through(self):
assert self.bi._make_json_serializable(None) is None
def test_string_passes_through(self):
assert self.bi._make_json_serializable("hello") == "hello"
def test_int_passes_through(self):
assert self.bi._make_json_serializable(42) == 42
def test_float_passes_through(self):
assert self.bi._make_json_serializable(3.14) == 3.14
def test_bool_passes_through(self):
assert self.bi._make_json_serializable(True) is True
def test_list_recurses(self):
result = self.bi._make_json_serializable([1, "two", None])
assert result == [1, "two", None]
def test_tuple_becomes_list(self):
result = self.bi._make_json_serializable((1, 2))
assert result == [1, 2]
def test_dict_recurses(self):
result = self.bi._make_json_serializable({"a": 1, "b": [2, 3]})
assert result == {"a": 1, "b": [2, 3]}
def test_enum_like_uses_name(self):
obj = Mock(spec=["name"])
obj.name = "MY_ENUM"
result = self.bi._make_json_serializable(obj)
assert result == "MY_ENUM"
def test_value_attr_used_when_no_name(self):
obj = Mock(spec=["value"])
obj.value = 99
result = self.bi._make_json_serializable(obj)
assert result == 99
def test_object_with_dict_converted(self):
class Dummy:
def __init__(self):
self.x = 1
self.y = "a"
result = self.bi._make_json_serializable(Dummy())
assert result == {"x": 1, "y": "a"}
def test_unknown_object_stringified(self):
# An object with no __dict__, no name, no value
result = self.bi._make_json_serializable(object())
assert isinstance(result, str)
def test_max_depth_stringifies(self):
# At max_depth, return str
result = self.bi._make_json_serializable({"nested": [1]}, depth=4, max_depth=3)
assert isinstance(result, str)
def test_deeply_nested_dict(self):
d = {"a": {"b": {"c": "deep"}}}
result = self.bi._make_json_serializable(d)
assert result["a"]["b"]["c"] == "deep"
# ---------------------------------------------------------------------------
# _insert_packet_stream_row
# ---------------------------------------------------------------------------
class TestInsertPacketStreamRow:
def test_enqueues_tuple(self):
bi = _make_bot_integration()
bi._insert_packet_stream_row('{"x": 1}', "packet")
assert not bi._write_queue.empty()
ts, data, row_type = bi._write_queue.get_nowait()
assert data == '{"x": 1}'
assert row_type == "packet"
def test_queue_exception_logged(self):
bi = _make_bot_integration()
bi._write_queue = Mock()
bi._write_queue.put_nowait.side_effect = Exception("full")
# Should not raise
bi._insert_packet_stream_row("{}", "packet")
bi.bot.logger.warning.assert_called_once()
# ---------------------------------------------------------------------------
# capture_full_packet_data
# ---------------------------------------------------------------------------
class TestCaptureFullPacketData:
def test_dict_packet_data_queued(self):
bi = _make_bot_integration()
bi.capture_full_packet_data({"snr": -5.0, "path_len": 2})
assert not bi._write_queue.empty()
ts, data, row_type = bi._write_queue.get_nowait()
parsed = json.loads(data)
assert row_type == "packet"
assert parsed["hops"] == 2
def test_no_path_len_defaults_hops_to_0(self):
bi = _make_bot_integration()
bi.capture_full_packet_data({"snr": -5.0})
ts, data, row_type = bi._write_queue.get_nowait()
parsed = json.loads(data)
assert parsed["hops"] == 0
def test_existing_hops_not_overwritten(self):
bi = _make_bot_integration()
bi.capture_full_packet_data({"hops": 3, "path_len": 2})
ts, data, row_type = bi._write_queue.get_nowait()
parsed = json.loads(data)
assert parsed["hops"] == 3
def test_datetime_added(self):
bi = _make_bot_integration()
bi.capture_full_packet_data({"snr": 0})
ts, data, _ = bi._write_queue.get_nowait()
parsed = json.loads(data)
assert "datetime" in parsed
def test_non_dict_wrapped(self):
bi = _make_bot_integration()
# Pass a non-dict (e.g. a Mock with __dict__)
bi.capture_full_packet_data("not a dict")
# Should not raise; may enqueue something
assert True # reached here without exception
# ---------------------------------------------------------------------------
# capture_command
# ---------------------------------------------------------------------------
class TestCaptureCommand:
def test_basic_capture_queued(self):
bi = _make_bot_integration()
msg = Mock()
msg.sender_id = "aa:bb"
msg.channel = "general"
msg.content = "ping"
bi.capture_command(msg, "ping", "Pong!", True)
assert not bi._write_queue.empty()
ts, data, row_type = bi._write_queue.get_nowait()
assert row_type == "command"
parsed = json.loads(data)
assert parsed["command"] == "ping"
assert parsed["success"] is True
def test_no_transmission_tracker(self):
bi = _make_bot_integration()
bi.bot.transmission_tracker = None
msg = Mock()
msg.sender_id = "u1"
msg.channel = "ch"
msg.content = "cmd"
bi.capture_command(msg, "cmd", "resp", True)
ts, data, _ = bi._write_queue.get_nowait()
parsed = json.loads(data)
assert parsed["repeat_count"] == 0
# ---------------------------------------------------------------------------
# capture_channel_message
# ---------------------------------------------------------------------------
class TestCaptureChannelMessage:
def test_message_queued_with_type_message(self):
bi = _make_bot_integration()
msg = Mock()
msg.sender_id = "aa"
msg.channel = "general"
msg.content = "hello"
msg.snr = -5.0
msg.hops = 1
msg.path = "bb,cc"
msg.is_dm = False
bi.capture_channel_message(msg)
assert not bi._write_queue.empty()
ts, data, row_type = bi._write_queue.get_nowait()
assert row_type == "message"
parsed = json.loads(data)
assert parsed["type"] == "message"
assert parsed["content"] == "hello"
def test_dm_message_captured(self):
bi = _make_bot_integration()
msg = Mock()
msg.sender_id = "aa"
msg.channel = ""
msg.content = "private"
msg.snr = -3.0
msg.hops = 0
msg.path = ""
msg.is_dm = True
bi.capture_channel_message(msg)
ts, data, _ = bi._write_queue.get_nowait()
parsed = json.loads(data)
assert parsed["is_dm"] is True
# ---------------------------------------------------------------------------
# capture_packet_routing
# ---------------------------------------------------------------------------
class TestCapturePacketRouting:
def test_routing_data_queued(self):
bi = _make_bot_integration()
bi.capture_packet_routing({"path_nodes": ["aa", "bb"]})
assert not bi._write_queue.empty()
ts, data, row_type = bi._write_queue.get_nowait()
assert row_type == "routing"
# ---------------------------------------------------------------------------
# _get_web_viewer_db_path
# ---------------------------------------------------------------------------
class TestGetWebViewerDbPath:
def test_uses_bot_db_path_when_no_section(self):
bi = _make_bot_integration()
bi.bot.config.remove_section("Web_Viewer")
bi.bot.db_manager.db_path = "/tmp/test.db"
result = bi._get_web_viewer_db_path()
assert "test.db" in result
def test_uses_web_viewer_db_path_when_set(self):
bi = _make_bot_integration()
bi.bot.config.set("Web_Viewer", "db_path", "/tmp/viewer.db")
result = bi._get_web_viewer_db_path()
assert "viewer.db" in result
def test_falls_back_when_web_viewer_db_path_empty(self):
bi = _make_bot_integration()
bi.bot.config.set("Web_Viewer", "db_path", "")
bi.bot.db_manager.db_path = "/tmp/bot.db"
result = bi._get_web_viewer_db_path()
assert "bot.db" in result
# ---------------------------------------------------------------------------
# WebViewerIntegration validation
# ---------------------------------------------------------------------------
class TestWebViewerIntegrationValidation:
def test_invalid_host_raises(self):
from modules.web_viewer.integration import WebViewerIntegration
bot = _make_bot()
bot.config.set("Web_Viewer", "host", "evil.host")
bot.config.set("Web_Viewer", "enabled", "false")
with patch.object(WebViewerIntegration, "start_viewer"):
with patch("modules.web_viewer.integration.BotIntegration._init_http_session"), \
patch("modules.web_viewer.integration.BotIntegration._init_packet_stream_table"), \
patch("modules.web_viewer.integration.BotIntegration._start_drain_thread"):
with pytest.raises(ValueError, match="Invalid host"):
WebViewerIntegration(bot)
def test_invalid_port_raises(self):
from modules.web_viewer.integration import WebViewerIntegration
bot = _make_bot()
bot.config.set("Web_Viewer", "port", "80") # privileged port
with patch("modules.web_viewer.integration.BotIntegration._init_http_session"), \
patch("modules.web_viewer.integration.BotIntegration._init_packet_stream_table"), \
patch("modules.web_viewer.integration.BotIntegration._start_drain_thread"):
with pytest.raises(ValueError, match="Port must be"):
WebViewerIntegration(bot)
def test_valid_config_no_error(self):
from modules.web_viewer.integration import WebViewerIntegration
bot = _make_bot()
with patch("modules.web_viewer.integration.BotIntegration._init_http_session"), \
patch("modules.web_viewer.integration.BotIntegration._init_packet_stream_table"), \
patch("modules.web_viewer.integration.BotIntegration._start_drain_thread"):
wvi = WebViewerIntegration(bot)
assert wvi.host == "127.0.0.1"
assert wvi.port == 8080
# ---------------------------------------------------------------------------
# shutdown
# ---------------------------------------------------------------------------
class TestShutdown:
def test_shutdown_sets_flag(self):
bi = _make_bot_integration()
bi._drain_thread = Mock()
bi._drain_thread.is_alive.return_value = False
bi.shutdown()
assert bi.is_shutting_down is True
def test_shutdown_stops_drain_thread(self):
bi = _make_bot_integration()
bi._drain_thread = Mock()
bi._drain_thread.is_alive.return_value = True
bi.shutdown()
bi._drain_thread.join.assert_called_once()
+271
View File
@@ -0,0 +1,271 @@
"""Tests for modules.commands.webviewer_command."""
import asyncio
import configparser
from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
from modules.commands.webviewer_command import WebViewerCommand
from tests.conftest import mock_message
# ---------------------------------------------------------------------------
# Bot factory
# ---------------------------------------------------------------------------
def _make_bot(enabled=True, has_integration=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("WebViewer_Command")
config.set("WebViewer_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)
bot.command_manager = MagicMock()
bot.command_manager.monitor_channels = ["general"]
bot.command_manager.send_response = AsyncMock(return_value=True)
if has_integration:
integration = MagicMock()
integration.enabled = True
integration.running = True
integration.host = "localhost"
integration.port = 5000
bot_int = MagicMock()
bot_int.circuit_breaker_open = False
bot_int.circuit_breaker_failures = 0
bot_int.is_shutting_down = False
integration.bot_integration = bot_int
bot.web_viewer_integration = integration
else:
bot.web_viewer_integration = None
return bot
def _run(coro):
return asyncio.run(coro)
# ---------------------------------------------------------------------------
# can_execute / enabled flag
# ---------------------------------------------------------------------------
class TestCanExecute:
def test_enabled_true(self):
cmd = WebViewerCommand(_make_bot(enabled=True))
msg = mock_message(content="webviewer status", is_dm=True)
assert cmd.can_execute(msg) is True
def test_enabled_false(self):
cmd = WebViewerCommand(_make_bot(enabled=False))
cmd.webviewer_enabled = False
msg = mock_message(content="webviewer status", is_dm=True)
assert cmd.can_execute(msg) is False
# ---------------------------------------------------------------------------
# matches_keyword
# ---------------------------------------------------------------------------
class TestMatchesKeyword:
def test_matches_webviewer(self):
cmd = WebViewerCommand(_make_bot())
msg = mock_message(content="webviewer status")
assert cmd.matches_keyword(msg) is True
def test_matches_web(self):
cmd = WebViewerCommand(_make_bot())
msg = mock_message(content="web status")
assert cmd.matches_keyword(msg) is True
def test_matches_wv(self):
cmd = WebViewerCommand(_make_bot())
msg = mock_message(content="wv status")
assert cmd.matches_keyword(msg) is True
def test_exact_match_webviewer(self):
cmd = WebViewerCommand(_make_bot())
msg = mock_message(content="webviewer")
assert cmd.matches_keyword(msg) is True
def test_no_match(self):
cmd = WebViewerCommand(_make_bot())
msg = mock_message(content="ping")
assert cmd.matches_keyword(msg) is False
def test_with_exclamation_prefix(self):
cmd = WebViewerCommand(_make_bot())
msg = mock_message(content="!webviewer status")
assert cmd.matches_keyword(msg) is True
# ---------------------------------------------------------------------------
# execute — no subcommand
# ---------------------------------------------------------------------------
class TestExecuteNoSubcommand:
def test_no_subcommand_shows_usage(self):
bot = _make_bot()
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer", is_dm=True)
_run(cmd.execute(msg))
bot.command_manager.send_response.assert_called_once()
call_args = bot.command_manager.send_response.call_args[0]
assert "Usage" in call_args[1] or "subcommand" in call_args[1].lower()
def test_exclamation_prefix_stripped(self):
bot = _make_bot()
cmd = WebViewerCommand(bot)
msg = mock_message(content="!webviewer", is_dm=True)
_run(cmd.execute(msg))
bot.command_manager.send_response.assert_called_once()
def test_returns_true(self):
bot = _make_bot()
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer", is_dm=True)
result = _run(cmd.execute(msg))
assert result is True
# ---------------------------------------------------------------------------
# execute — unknown subcommand
# ---------------------------------------------------------------------------
class TestExecuteUnknownSubcommand:
def test_unknown_subcommand_sends_error(self):
bot = _make_bot()
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer foobar", is_dm=True)
_run(cmd.execute(msg))
bot.command_manager.send_response.assert_called_once()
call_args = bot.command_manager.send_response.call_args[0]
assert "Unknown" in call_args[1] or "unknown" in call_args[1].lower()
# ---------------------------------------------------------------------------
# _handle_status
# ---------------------------------------------------------------------------
class TestHandleStatus:
def test_status_with_integration(self):
bot = _make_bot(has_integration=True)
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer status", is_dm=True)
_run(cmd.execute(msg))
bot.command_manager.send_response.assert_called_once()
call_args = bot.command_manager.send_response.call_args[0]
assert "Status" in call_args[1] or "status" in call_args[1].lower() or "enabled" in call_args[1]
def test_status_without_integration(self):
bot = _make_bot(has_integration=False)
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer status", is_dm=True)
_run(cmd.execute(msg))
bot.command_manager.send_response.assert_called_once()
call_args = bot.command_manager.send_response.call_args[0]
assert "not available" in call_args[1]
def test_status_without_bot_integration_attr(self):
bot = _make_bot(has_integration=True)
del bot.web_viewer_integration.bot_integration
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer status", is_dm=True)
_run(cmd.execute(msg))
bot.command_manager.send_response.assert_called_once()
def test_status_running_false(self):
bot = _make_bot(has_integration=True)
bot.web_viewer_integration.running = False
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer status", is_dm=True)
_run(cmd.execute(msg))
bot.command_manager.send_response.assert_called_once()
# ---------------------------------------------------------------------------
# _handle_reset
# ---------------------------------------------------------------------------
class TestHandleReset:
def test_reset_with_bot_integration(self):
bot = _make_bot(has_integration=True)
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer reset", is_dm=True)
_run(cmd.execute(msg))
bot.web_viewer_integration.bot_integration.reset_circuit_breaker.assert_called_once()
call_args = bot.command_manager.send_response.call_args[0]
assert "reset" in call_args[1].lower()
def test_reset_without_integration(self):
bot = _make_bot(has_integration=False)
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer reset", is_dm=True)
_run(cmd.execute(msg))
call_args = bot.command_manager.send_response.call_args[0]
assert "not available" in call_args[1]
def test_reset_without_bot_integration(self):
bot = _make_bot(has_integration=True)
bot.web_viewer_integration.bot_integration = None
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer reset", is_dm=True)
_run(cmd.execute(msg))
call_args = bot.command_manager.send_response.call_args[0]
assert "not available" in call_args[1]
# ---------------------------------------------------------------------------
# _handle_restart
# ---------------------------------------------------------------------------
class TestHandleRestart:
def test_restart_with_integration(self):
bot = _make_bot(has_integration=True)
bot.web_viewer_integration.restart_viewer = Mock()
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer restart", is_dm=True)
_run(cmd.execute(msg))
bot.web_viewer_integration.restart_viewer.assert_called_once()
call_args = bot.command_manager.send_response.call_args[0]
assert "restart" in call_args[1].lower()
def test_restart_without_integration(self):
bot = _make_bot(has_integration=False)
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer restart", is_dm=True)
_run(cmd.execute(msg))
call_args = bot.command_manager.send_response.call_args[0]
assert "not available" in call_args[1]
def test_restart_exception_handled(self):
bot = _make_bot(has_integration=True)
bot.web_viewer_integration.restart_viewer = Mock(side_effect=Exception("crash"))
cmd = WebViewerCommand(bot)
msg = mock_message(content="webviewer restart", is_dm=True)
_run(cmd.execute(msg))
call_args = bot.command_manager.send_response.call_args[0]
assert "Failed" in call_args[1] or "failed" in call_args[1].lower()
# ---------------------------------------------------------------------------
# get_help_text
# ---------------------------------------------------------------------------
class TestGetHelpText:
def test_returns_usage_string(self):
cmd = WebViewerCommand(_make_bot())
result = cmd.get_help_text()
assert "webviewer" in result.lower() or "Usage" in result