Files
meshcore-bot/tests/test_command_prefix.py
T
agessaman 24fdfb2370 feat(command_manager, help_command): improve command prefix handling and help command visibility
- Enhanced `CommandManager` to persist normalized message content, preventing prefix stripping from interfering with command matching.
- Updated `BaseCommand` to skip prefix handling if already normalized, ensuring all commands can be matched correctly.
- Modified `HelpCommand` to ensure unused commands are listed even when command statistics are present, improving user accessibility to all commands.
- Added unit tests to verify the correct behavior of command matching and help command functionality under various scenarios.
2026-06-28 08:42:58 -07:00

369 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Unit tests for command prefix functionality
Tests that all commands properly handle command prefixes when enabled
"""
from configparser import ConfigParser
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from modules.command_manager import CommandManager
from modules.commands.base_command import BaseCommand
from modules.commands.hello_command import HelloCommand
from modules.commands.ping_command import PingCommand
from modules.models import MeshMessage
class MockTestCommand(BaseCommand):
"""Mock command for testing prefix functionality"""
name = "test"
keywords = ['test', 't']
description = "Test command"
category = "test"
async def execute(self, message: MeshMessage) -> bool:
"""Execute the test command (required by abstract base class)"""
return True
@pytest.fixture
def mock_bot():
"""Create a mock bot instance"""
bot = Mock()
bot.logger = Mock()
bot.logger.debug = Mock()
bot.logger.info = Mock()
bot.logger.warning = Mock()
bot.logger.error = Mock()
bot.config = ConfigParser()
bot.config.add_section('Bot')
bot.config.add_section('Channels')
bot.config.set('Channels', 'monitor_channels', 'general')
bot.config.set('Channels', 'respond_to_dms', 'true')
bot.meshcore = None
bot.translator = None
bot.rate_limiter = Mock()
bot.rate_limiter.can_send = Mock(return_value=True)
bot.bot_tx_rate_limiter = Mock()
bot.bot_tx_rate_limiter.wait_for_tx = Mock()
bot.tx_delay_ms = 0
bot.bot_root = Path("/tmp")
bot._local_root = None # CommandManager uses bot_root / local / commands
return bot
@pytest.fixture
def mock_message():
"""Create a mock message"""
return MeshMessage(
content="test",
sender_id="TestUser",
channel="general",
is_dm=False
)
class TestCommandPrefix:
"""Test command prefix functionality"""
def test_no_prefix_allows_commands(self, mock_bot, mock_message):
"""Test that without prefix configured, commands work normally"""
mock_bot.config.set('Bot', 'command_prefix', '')
command = MockTestCommand(mock_bot)
# Should match without prefix
assert command.matches_keyword(mock_message) is True
# Should also match with legacy ! prefix
mock_message.content = "!test"
assert command.matches_keyword(mock_message) is True
def test_prefix_required_when_configured(self, mock_bot, mock_message):
"""Test that when prefix is configured, it's required"""
mock_bot.config.set('Bot', 'command_prefix', '!')
command = MockTestCommand(mock_bot)
# Should match with prefix
mock_message.content = "!test"
assert command.matches_keyword(mock_message) is True
# Should NOT match without prefix
mock_message.content = "test"
assert command.matches_keyword(mock_message) is False
def test_dot_prefix(self, mock_bot, mock_message):
"""Test dot prefix (e.g., .ping)"""
mock_bot.config.set('Bot', 'command_prefix', '.')
command = MockTestCommand(mock_bot)
# Should match with dot prefix
mock_message.content = ".test"
assert command.matches_keyword(mock_message) is True
# Should NOT match without prefix
mock_message.content = "test"
assert command.matches_keyword(mock_message) is False
def test_single_char_prefix(self, mock_bot, mock_message):
"""Test single character prefix (e.g., bping)"""
mock_bot.config.set('Bot', 'command_prefix', 'b')
command = MockTestCommand(mock_bot)
# Should match with 'b' prefix
mock_message.content = "btest"
assert command.matches_keyword(mock_message) is True
# Should NOT match without prefix
mock_message.content = "test"
assert command.matches_keyword(mock_message) is False
def test_multi_char_prefix(self, mock_bot, mock_message):
"""Test multi-character prefix (e.g., abcping)"""
mock_bot.config.set('Bot', 'command_prefix', 'abc')
command = MockTestCommand(mock_bot)
# Should match with 'abc' prefix
mock_message.content = "abctest"
assert command.matches_keyword(mock_message) is True
# Should NOT match without prefix
mock_message.content = "test"
assert command.matches_keyword(mock_message) is False
# Should NOT match with partial prefix
mock_message.content = "abtest"
assert command.matches_keyword(mock_message) is False
def test_prefix_with_whitespace(self, mock_bot, mock_message):
"""Test that prefix works with whitespace after it"""
mock_bot.config.set('Bot', 'command_prefix', '!')
command = MockTestCommand(mock_bot)
# Should match with prefix and space
mock_message.content = "! test"
assert command.matches_keyword(mock_message) is True
# Should match with prefix and no space
mock_message.content = "!test"
assert command.matches_keyword(mock_message) is True
def test_prefix_with_keyword_variations(self, mock_bot, mock_message):
"""Test prefix with different keyword variations"""
mock_bot.config.set('Bot', 'command_prefix', '!')
command = MockTestCommand(mock_bot)
# Test first keyword
mock_message.content = "!test"
assert command.matches_keyword(mock_message) is True
# Test second keyword
mock_message.content = "!t"
assert command.matches_keyword(mock_message) is True
# Test keyword with arguments
mock_message.content = "!test arg1 arg2"
assert command.matches_keyword(mock_message) is True
def test_hello_command_with_prefix(self, mock_bot, mock_message):
"""Test hello command specifically with prefix"""
mock_bot.config.set('Bot', 'command_prefix', '!')
mock_bot.config.set('Bot', 'bot_name', 'TestBot')
mock_bot.config.add_section('Hello_Command')
mock_bot.config.set('Hello_Command', 'enabled', 'true')
command = HelloCommand(mock_bot)
# Should match with prefix
mock_message.content = "!hello"
assert command.matches_keyword(mock_message) is True
# Should NOT match without prefix
mock_message.content = "hello"
assert command.matches_keyword(mock_message) is False
def test_ping_command_with_prefix(self, mock_bot, mock_message):
"""Test ping command with prefix"""
mock_bot.config.set('Bot', 'command_prefix', '.')
mock_bot.config.add_section('Ping_Command')
mock_bot.config.set('Ping_Command', 'enabled', 'true')
command = PingCommand(mock_bot)
# Should match with dot prefix
mock_message.content = ".ping"
assert command.matches_keyword(mock_message) is True
# Should NOT match without prefix
mock_message.content = "ping"
assert command.matches_keyword(mock_message) is False
def test_command_manager_with_prefix(self, mock_bot, mock_message):
"""Test CommandManager handles prefix correctly"""
mock_bot.config.set('Bot', 'command_prefix', '!')
mock_bot.config.add_section('Keywords')
mock_bot.config.set('Keywords', 'keywords', '')
mock_bot.config.add_section('Custom_Syntax')
mock_bot.config.set('Custom_Syntax', 'custom_syntax', '')
# Mock plugin loader to return empty commands for simplicity
with patch('modules.command_manager.PluginLoader') as mock_loader_class:
mock_loader = Mock()
mock_loader.load_all_plugins = Mock(return_value={})
mock_loader_class.return_value = mock_loader
manager = CommandManager(mock_bot)
# Should return empty matches for message without prefix
mock_message.content = "test"
matches = manager.check_keywords(mock_message)
assert matches == []
# Should process message with prefix
mock_message.content = "!test"
matches = manager.check_keywords(mock_message)
# Will be empty because no commands loaded, but should process without error
assert isinstance(matches, list)
def test_prefix_with_mentions(self, mock_bot, mock_message):
"""Test that prefix works correctly with @[username] mentions"""
mock_bot.config.set('Bot', 'command_prefix', '!')
mock_bot.config.set('Bot', 'bot_name', 'TestBot')
command = MockTestCommand(mock_bot)
# Mock self_info to return bot name
mock_bot.meshcore = Mock()
mock_bot.meshcore.self_info = {'name': 'TestBot'}
# Should match with prefix and bot mention
mock_message.content = "!@[TestBot] test"
assert command.matches_keyword(mock_message) is True
# Should NOT match with prefix but other user mention
mock_message.content = "!@[OtherUser] test"
assert command.matches_keyword(mock_message) is False
def test_different_prefixes_dont_match(self, mock_bot, mock_message):
"""Test that wrong prefix doesn't match"""
mock_bot.config.set('Bot', 'command_prefix', '!')
command = MockTestCommand(mock_bot)
# Should NOT match with wrong prefix
mock_message.content = ".test"
assert command.matches_keyword(mock_message) is False
mock_message.content = "btest"
assert command.matches_keyword(mock_message) is False
mock_message.content = "abctest"
assert command.matches_keyword(mock_message) is False
def test_prefix_case_sensitive(self, mock_bot, mock_message):
"""Test that prefix matching is case-sensitive"""
mock_bot.config.set('Bot', 'command_prefix', '!')
command = MockTestCommand(mock_bot)
# Should match with exact prefix
mock_message.content = "!test"
assert command.matches_keyword(mock_message) is True
# Prefix matching is case-sensitive, so different case shouldn't match
# (This tests the actual behavior - prefixes are case-sensitive)
mock_message.content = "!TEST" # Prefix is still '!', so this should match
assert command.matches_keyword(mock_message) is True # '!' is same case
# But if prefix is lowercase, uppercase shouldn't match
mock_bot.config.set('Bot', 'command_prefix', 'b')
command = MockTestCommand(mock_bot)
mock_message.content = "Btest" # Uppercase B
assert command.matches_keyword(mock_message) is False # Should not match lowercase 'b'
def test_empty_prefix_string(self, mock_bot, mock_message):
"""Test that empty string prefix means no prefix required"""
mock_bot.config.set('Bot', 'command_prefix', '')
command = MockTestCommand(mock_bot)
# Should match without prefix
mock_message.content = "test"
assert command.matches_keyword(mock_message) is True
# Should also match with legacy ! prefix
mock_message.content = "!test"
assert command.matches_keyword(mock_message) is True
class MockAlphaCommand(BaseCommand):
"""Second mock command, used to exercise multi-command iteration."""
name = "alpha"
keywords = ['alpha']
description = "Alpha command"
category = "test"
async def execute(self, message: MeshMessage) -> bool:
return True
class TestCommandPrefixMultipleCommands:
"""Regression tests for issue #137.
With a configured prefix, the bot must respond to every command, not just the
first one in iteration order. The original bug: cleanup_message_for_matching
mutated the shared message (stripping the prefix), so the first command consumed
the prefix and every later command then failed its own prefix check.
"""
def _make_manager(self, mock_bot, commands):
for section in ('Keywords', 'Custom_Syntax'):
if not mock_bot.config.has_section(section):
mock_bot.config.add_section(section)
with patch('modules.command_manager.PluginLoader') as mock_loader_class:
loader = Mock()
loader.load_all_plugins = Mock(return_value=commands)
loader.keyword_mappings = {}
mock_loader_class.return_value = loader
manager = CommandManager(mock_bot)
# Commands resolve channel access via bot.command_manager at call time.
mock_bot.command_manager = manager
return manager
def test_non_first_command_matches_with_prefix(self, mock_bot, mock_message):
"""A command that is NOT first in iteration order still matches with a prefix."""
mock_bot.config.set('Bot', 'command_prefix', '!')
# 'alpha' is iterated before 'test'; under the old bug it would strip the
# prefix and leave 'test' unable to match.
commands = {'alpha': MockAlphaCommand(mock_bot), 'test': MockTestCommand(mock_bot)}
manager = self._make_manager(mock_bot, commands)
mock_message.content = "!test"
matches = manager.check_keywords(mock_message)
assert any(trigger == 'test' for trigger, _ in matches)
def test_first_command_still_matches_with_prefix(self, mock_bot, mock_message):
"""The first-iterated command continues to match with a prefix."""
mock_bot.config.set('Bot', 'command_prefix', '!')
commands = {'alpha': MockAlphaCommand(mock_bot), 'test': MockTestCommand(mock_bot)}
manager = self._make_manager(mock_bot, commands)
mock_message.content = "!alpha"
matches = manager.check_keywords(mock_message)
assert any(trigger == 'alpha' for trigger, _ in matches)
def test_bare_command_blocked_when_prefix_required(self, mock_bot, mock_message):
"""Prefix enforcement still rejects unprefixed messages (now centralized)."""
mock_bot.config.set('Bot', 'command_prefix', '!')
commands = {'alpha': MockAlphaCommand(mock_bot), 'test': MockTestCommand(mock_bot)}
manager = self._make_manager(mock_bot, commands)
mock_message.content = "test"
assert manager.check_keywords(mock_message) == []
def test_multiple_commands_match_without_prefix(self, mock_bot, mock_message):
"""Without a prefix, a non-first command still matches (no regression)."""
mock_bot.config.set('Bot', 'command_prefix', '')
commands = {'alpha': MockAlphaCommand(mock_bot), 'test': MockTestCommand(mock_bot)}
manager = self._make_manager(mock_bot, commands)
mock_message.content = "test"
matches = manager.check_keywords(mock_message)
assert any(trigger == 'test' for trigger, _ in matches)