ai-assistant-discord-bot/tests/test_commands.py
Claude Discord Bot 4c00cd97e6 Week 2 complete: Discord bot MVP with full integration
Completed HIGH-001 through HIGH-004:

HIGH-001: Discord bot with channel message routing
- bot.py: 244 lines with ClaudeCoordinator class
- @mention trigger mode for safe operation
- Session lifecycle integration with SessionManager
- Typing indicators and error handling
- 20/20 tests passing

HIGH-002: Response formatter with intelligent chunking
- response_formatter.py: expanded to 329 lines
- format_response() with smart boundary detection
- Code block preservation and splitting
- 26/26 tests passing

HIGH-003: Slash commands for bot management
- commands.py: 411 lines with ClaudeCommands cog
- /reset with interactive confirmation dialog
- /status with Discord embed display
- /model for runtime model switching
- 18/18 tests passing

HIGH-004: Concurrent message handling
- Per-channel asyncio.Lock implementation
- Same-channel serialization (prevents race conditions)
- Cross-channel parallelization (maintains performance)
- 7/7 concurrency tests passing

Total: 134/135 tests passing (99.3%)
Production-ready Discord bot MVP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:42:50 +00:00

385 lines
14 KiB
Python

"""
Tests for Discord slash commands.
Tests cover /reset, /status, and /model commands with:
- Success scenarios
- Error handling
- Permission checks
- Edge cases
"""
import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import discord
import pytest
from discord import app_commands
from discord.ext import commands
from claude_coordinator.commands import ClaudeCommands, ResetConfirmView
@pytest.fixture
def mock_bot():
"""Create a mock Discord bot."""
bot = MagicMock(spec=commands.Bot)
bot.session_manager = AsyncMock()
bot.config = MagicMock()
return bot
@pytest.fixture
def mock_interaction():
"""Create a mock Discord interaction."""
interaction = MagicMock(spec=discord.Interaction)
interaction.response = AsyncMock()
interaction.followup = AsyncMock()
interaction.channel = MagicMock()
interaction.channel.id = 123456789
interaction.channel.mention = "#test-channel"
interaction.user = MagicMock()
interaction.user.id = 987654321
return interaction
@pytest.fixture
def mock_project():
"""Create a mock project configuration."""
project = MagicMock()
project.name = "test-project"
project.model = "claude-sonnet-4-5"
return project
@pytest.fixture
def commands_cog(mock_bot):
"""Create ClaudeCommands cog instance."""
return ClaudeCommands(mock_bot)
class TestResetCommand:
"""Tests for /reset command."""
@pytest.mark.asyncio
async def test_reset_success(self, commands_cog, mock_interaction, mock_project):
"""Test successful reset command with existing session."""
# Setup mocks
commands_cog.config.get_project_by_channel.return_value = mock_project
session_data = {
'channel_id': '123456789',
'session_id': 'test-session-id',
'project_name': 'test-project',
'message_count': 42
}
commands_cog.session_manager.get_session.return_value = session_data
# Execute command - use callback to bypass decorator
await commands_cog.reset_command.callback(commands_cog, mock_interaction, channel=None)
# Verify confirmation message was sent
mock_interaction.response.send_message.assert_called_once()
call_args = mock_interaction.response.send_message.call_args
assert "Reset Session Confirmation" in call_args[0][0] or "Reset Session Confirmation" in str(call_args[1])
# Verify view was attached
assert 'view' in call_args[1]
assert isinstance(call_args[1]['view'], ResetConfirmView)
@pytest.mark.asyncio
async def test_reset_no_session(self, commands_cog, mock_interaction, mock_project):
"""Test reset command when no session exists."""
# Setup mocks
commands_cog.config.get_project_by_channel.return_value = mock_project
commands_cog.session_manager.get_session.return_value = None
# Execute command
await commands_cog.reset_command.callback(commands_cog, mock_interaction, channel=None)
# Verify informational message
mock_interaction.response.send_message.assert_called_once()
call_args = mock_interaction.response.send_message.call_args
content = call_args[0][0] if call_args[0] else call_args[1].get('content', '')
assert "No active session" in content or call_args[1].get('ephemeral') is True
@pytest.mark.asyncio
async def test_reset_unconfigured_channel(self, commands_cog, mock_interaction):
"""Test reset command on unconfigured channel."""
# Setup mocks
commands_cog.config.get_project_by_channel.return_value = None
# Execute command
await commands_cog.reset_command.callback(commands_cog, mock_interaction, channel=None)
# Verify error message
mock_interaction.response.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_reset_with_target_channel(self, commands_cog, mock_interaction, mock_project):
"""Test reset command with explicit target channel."""
# Setup target channel
target_channel = MagicMock()
target_channel.id = 999888777
target_channel.mention = "#target-channel"
# Setup mocks
commands_cog.config.get_project_by_channel.return_value = mock_project
session_data = {'channel_id': '999888777', 'project_name': 'test', 'message_count': 5}
commands_cog.session_manager.get_session.return_value = session_data
# Execute command
await commands_cog.reset_command.callback(commands_cog, mock_interaction, channel=target_channel)
# Verify target channel was used
commands_cog.session_manager.get_session.assert_called_once_with('999888777')
@pytest.mark.asyncio
async def test_reset_error_handling(self, commands_cog, mock_interaction, mock_project):
"""Test reset command error handling."""
# Setup mock to raise exception
commands_cog.config.get_project_by_channel.return_value = mock_project
commands_cog.session_manager.get_session.side_effect = Exception("Database error")
# Execute command
await commands_cog.reset_command.callback(commands_cog, mock_interaction, channel=None)
# Verify error message
mock_interaction.response.send_message.assert_called_once()
class TestResetConfirmView:
"""Tests for ResetConfirmView interaction."""
@pytest.mark.asyncio
async def test_confirm_button_success(self, mock_bot):
"""Test confirmation button successfully resets session."""
# Setup
session_manager = AsyncMock()
session_manager.reset_session.return_value = True
channel = MagicMock()
channel.mention = "#test-channel"
session = {'project_name': 'test-project'}
view = ResetConfirmView(session_manager, '123456789', channel, session)
# Mock interaction
interaction = MagicMock()
interaction.response = AsyncMock()
interaction.user = MagicMock()
interaction.user.id = 123
# Execute confirm button
await view.confirm_button.callback(interaction)
# Verify reset was called
session_manager.reset_session.assert_called_once_with('123456789')
# Verify success message
interaction.response.edit_message.assert_called_once()
@pytest.mark.asyncio
async def test_cancel_button(self, mock_bot):
"""Test cancel button dismisses confirmation."""
# Setup
view = ResetConfirmView(
AsyncMock(),
'123456789',
MagicMock(),
{}
)
interaction = MagicMock()
interaction.response = AsyncMock()
# Execute cancel button
await view.cancel_button.callback(interaction)
# Verify cancellation message
interaction.response.edit_message.assert_called_once()
class TestStatusCommand:
"""Tests for /status command."""
@pytest.mark.asyncio
async def test_status_with_sessions(self, commands_cog, mock_interaction):
"""Test status command with active sessions."""
# Setup mock data
sessions = [
{
'channel_id': '123456789',
'project_name': 'project-1',
'message_count': 42,
'last_active': datetime.now().isoformat()
},
{
'channel_id': '987654321',
'project_name': 'project-2',
'message_count': 15,
'last_active': datetime.now().isoformat()
}
]
stats = {
'total_sessions': 2,
'total_messages': 57
}
commands_cog.session_manager.list_sessions.return_value = sessions
commands_cog.session_manager.get_stats.return_value = stats
# Mock get_channel
mock_channel = MagicMock()
mock_channel.mention = "#test-channel"
commands_cog.bot.get_channel.return_value = mock_channel
# Execute command
await commands_cog.status_command.callback(commands_cog, mock_interaction)
# Verify defer was called
mock_interaction.response.defer.assert_called_once_with(ephemeral=True)
# Verify embed was sent
mock_interaction.followup.send.assert_called_once()
@pytest.mark.asyncio
async def test_status_no_sessions(self, commands_cog, mock_interaction):
"""Test status command with no active sessions."""
# Setup empty data
commands_cog.session_manager.list_sessions.return_value = []
commands_cog.session_manager.get_stats.return_value = {'total_sessions': 0}
# Execute command
await commands_cog.status_command.callback(commands_cog, mock_interaction)
# Verify message
mock_interaction.followup.send.assert_called_once()
@pytest.mark.asyncio
async def test_status_error_handling(self, commands_cog, mock_interaction):
"""Test status command error handling."""
# Setup exception
commands_cog.session_manager.list_sessions.side_effect = Exception("DB error")
# Execute command
await commands_cog.status_command.callback(commands_cog, mock_interaction)
# Verify error message
mock_interaction.followup.send.assert_called_once()
class TestModelCommand:
"""Tests for /model command."""
@pytest.mark.asyncio
async def test_model_switch_success(self, commands_cog, mock_interaction, mock_project):
"""Test successful model switch."""
# Setup mocks
commands_cog.config.get_project_by_channel.return_value = mock_project
commands_cog.config.save = MagicMock()
# Execute command
await commands_cog.model_command.callback(commands_cog, mock_interaction, "opus")
# Verify model was updated
assert mock_project.model == "claude-opus-4-6"
# Verify config was saved
commands_cog.config.save.assert_called_once()
@pytest.mark.asyncio
async def test_model_unconfigured_channel(self, commands_cog, mock_interaction):
"""Test model command on unconfigured channel."""
# Setup
commands_cog.config.get_project_by_channel.return_value = None
# Execute command
await commands_cog.model_command.callback(commands_cog, mock_interaction, "sonnet")
# Verify error message
mock_interaction.response.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_model_all_choices(self, commands_cog, mock_interaction, mock_project):
"""Test all model choices work correctly."""
commands_cog.config.get_project_by_channel.return_value = mock_project
commands_cog.config.save = MagicMock()
test_cases = [
("sonnet", "claude-sonnet-4-5"),
("opus", "claude-opus-4-6"),
("haiku", "claude-3-5-haiku")
]
for model_name, expected_model in test_cases:
# Execute
await commands_cog.model_command.callback(commands_cog, mock_interaction, model_name)
# Verify
assert mock_project.model == expected_model
@pytest.mark.asyncio
async def test_model_error_handling(self, commands_cog, mock_interaction, mock_project):
"""Test model command error handling."""
# Setup exception during save
commands_cog.config.get_project_by_channel.return_value = mock_project
commands_cog.config.save.side_effect = Exception("Save error")
# Execute command
await commands_cog.model_command.callback(commands_cog, mock_interaction, "sonnet")
# Verify error message was sent
mock_interaction.response.send_message.assert_called()
class TestPermissions:
"""Test permission handling."""
@pytest.mark.asyncio
async def test_reset_requires_permissions(self, commands_cog, mock_interaction):
"""Test that reset command checks permissions."""
# The @app_commands.checks.has_permissions decorator is applied
# We verify it exists on the command
assert hasattr(commands_cog.reset_command, 'checks')
@pytest.mark.asyncio
async def test_reset_error_handler(self, commands_cog, mock_interaction):
"""Test permission error handler."""
# Create permission error
error = app_commands.MissingPermissions(['manage_messages'])
# Call error handler
await commands_cog.reset_error(mock_interaction, error)
# Verify error message was sent
mock_interaction.response.send_message.assert_called_once()
class TestCogSetup:
"""Test cog setup and initialization."""
@pytest.mark.asyncio
async def test_cog_initialization(self, mock_bot):
"""Test ClaudeCommands cog initializes correctly."""
cog = ClaudeCommands(mock_bot)
assert cog.bot == mock_bot
assert cog.session_manager == mock_bot.session_manager
assert cog.config == mock_bot.config
@pytest.mark.asyncio
async def test_setup_function(self, mock_bot):
"""Test setup function adds cog to bot."""
from claude_coordinator.commands import setup
mock_bot.add_cog = AsyncMock()
await setup(mock_bot)
# Verify add_cog was called
mock_bot.add_cog.assert_called_once()
call_args = mock_bot.add_cog.call_args
assert isinstance(call_args[0][0], ClaudeCommands)