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>
385 lines
14 KiB
Python
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)
|