ai-assistant-discord-bot/tests/test_bot.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

456 lines
19 KiB
Python

"""
Tests for Discord bot message routing and Claude integration.
Tests cover:
- Message routing logic
- @mention detection
- Session creation vs resumption
- Integration with ClaudeRunner, SessionManager, Config
- Error handling
- Response formatting
"""
import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, Mock, patch, PropertyMock
from pathlib import Path
import pytest
from claude_coordinator.bot import ClaudeCoordinator
from claude_coordinator.config import ProjectConfig
from claude_coordinator.claude_runner import ClaudeResponse
@pytest.fixture
def mock_discord_user():
"""Create a mock Discord user."""
user = MagicMock()
user.id = 123456789
user.bot = False
user.name = "TestUser"
return user
@pytest.fixture
def mock_bot_user():
"""Create a mock bot user."""
bot_user = MagicMock()
bot_user.id = 987654321
bot_user.bot = True
bot_user.name = "ClaudeCoordinator"
return bot_user
@pytest.fixture
def mock_discord_message(mock_discord_user, mock_bot_user):
"""Create a mock Discord message."""
message = MagicMock()
message.author = mock_discord_user
message.content = f"<@{mock_bot_user.id}> Hello Claude!"
message.mentions = [mock_bot_user]
message.channel = MagicMock()
message.channel.id = 111222333444
message.channel.send = AsyncMock()
message.channel.typing = MagicMock()
# Make typing() work as async context manager
message.channel.typing.return_value.__aenter__ = AsyncMock()
message.channel.typing.return_value.__aexit__ = AsyncMock()
return message
@pytest.fixture
def mock_project_config():
"""Create a mock ProjectConfig."""
return ProjectConfig(
name="test-project",
channel_id="111222333444",
project_dir="/tmp/test-project",
allowed_tools=["Bash", "Read", "Write"],
system_prompt="You are a test assistant.",
model="sonnet"
)
@pytest.fixture
def mock_claude_response():
"""Create a successful mock ClaudeResponse."""
return ClaudeResponse(
success=True,
result="This is Claude's response to your message.",
session_id="test-session-uuid-1234",
cost=0.001,
duration_ms=1500,
permission_denials=[]
)
class TestBotInitialization:
"""Tests for bot initialization and setup."""
def test_bot_creates_with_default_config(self):
"""Test bot initializes with default configuration paths."""
bot = ClaudeCoordinator()
assert bot.config is not None
assert bot.session_manager is not None
assert bot.claude_runner is not None
assert bot.response_formatter is not None
def test_bot_creates_with_custom_paths(self):
"""Test bot initializes with custom config and database paths."""
bot = ClaudeCoordinator(
config_path="/tmp/test-config.yaml",
db_path="/tmp/test-sessions.db"
)
assert bot.config.config_path == Path("/tmp/test-config.yaml")
assert bot.session_manager.db_path == "/tmp/test-sessions.db"
def test_bot_has_message_content_intent(self):
"""Test bot enables message_content intent."""
bot = ClaudeCoordinator()
assert bot.intents.message_content is True
class TestMessageFiltering:
"""Tests for message filtering logic."""
@pytest.mark.asyncio
async def test_ignores_bot_messages(self, mock_discord_message, mock_bot_user):
"""Test bot ignores messages from other bots."""
bot = ClaudeCoordinator()
mock_discord_message.author.bot = True
with patch.object(bot, '_handle_claude_request', new_callable=AsyncMock) as mock_handle:
await bot.on_message(mock_discord_message)
mock_handle.assert_not_called()
@pytest.mark.asyncio
async def test_ignores_messages_without_mention(self, mock_discord_message, mock_bot_user):
"""Test bot ignores messages that don't mention it."""
bot = ClaudeCoordinator()
# Mock the user property
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = mock_bot_user
mock_discord_message.mentions = []
with patch.object(bot, '_handle_claude_request', new_callable=AsyncMock) as mock_handle:
await bot.on_message(mock_discord_message)
mock_handle.assert_not_called()
@pytest.mark.asyncio
async def test_ignores_unconfigured_channel(self, mock_discord_message, mock_bot_user):
"""Test bot ignores messages from unconfigured channels."""
bot = ClaudeCoordinator()
bot.config.get_project_by_channel = MagicMock(return_value=None)
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = mock_bot_user
with patch.object(bot, '_handle_claude_request', new_callable=AsyncMock) as mock_handle:
await bot.on_message(mock_discord_message)
mock_handle.assert_not_called()
@pytest.mark.asyncio
async def test_processes_valid_message_with_mention(
self, mock_discord_message, mock_bot_user, mock_project_config
):
"""Test bot processes valid message with @mention in configured channel."""
bot = ClaudeCoordinator()
bot.config.get_project_by_channel = MagicMock(return_value=mock_project_config)
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = mock_bot_user
with patch.object(bot, '_handle_claude_request', new_callable=AsyncMock) as mock_handle:
await bot.on_message(mock_discord_message)
mock_handle.assert_called_once_with(mock_discord_message, mock_project_config)
class TestMessageContentExtraction:
"""Tests for extracting clean message content."""
def test_removes_bot_mention(self, mock_bot_user):
"""Test bot mention is removed from message content."""
bot = ClaudeCoordinator()
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = mock_bot_user
message = MagicMock()
message.content = f"<@{mock_bot_user.id}> Hello Claude!"
extracted = bot._extract_message_content(message)
assert extracted == "Hello Claude!"
assert f"<@{mock_bot_user.id}>" not in extracted
def test_removes_nickname_mention(self, mock_bot_user):
"""Test bot nickname mention (with !) is removed."""
bot = ClaudeCoordinator()
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = mock_bot_user
message = MagicMock()
message.content = f"<@!{mock_bot_user.id}> Test message"
extracted = bot._extract_message_content(message)
assert extracted == "Test message"
assert f"<@!{mock_bot_user.id}>" not in extracted
def test_strips_whitespace(self, mock_bot_user):
"""Test extracted content is stripped of leading/trailing whitespace."""
bot = ClaudeCoordinator()
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = mock_bot_user
message = MagicMock()
message.content = f"<@{mock_bot_user.id}> Test "
extracted = bot._extract_message_content(message)
assert extracted == "Test"
class TestSessionManagement:
"""Tests for session creation and resumption."""
@pytest.mark.asyncio
async def test_creates_new_session_when_none_exists(
self, mock_discord_message, mock_project_config, mock_claude_response
):
"""Test creates new session when channel has no existing session."""
bot = ClaudeCoordinator()
bot.session_manager.get_session = AsyncMock(return_value=None)
bot.session_manager.save_session = AsyncMock()
bot.session_manager.update_activity = AsyncMock()
bot.claude_runner.run = AsyncMock(return_value=mock_claude_response)
bot.response_formatter.format_response = MagicMock(return_value=["Response"])
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = MagicMock(id=987654321)
await bot._handle_claude_request(mock_discord_message, mock_project_config)
# Should call claude_runner with session_id=None for new session
bot.claude_runner.run.assert_called_once()
call_kwargs = bot.claude_runner.run.call_args.kwargs
assert call_kwargs['session_id'] is None
@pytest.mark.asyncio
async def test_resumes_existing_session(
self, mock_discord_message, mock_project_config, mock_claude_response
):
"""Test resumes existing session when channel has active session."""
bot = ClaudeCoordinator()
existing_session = {
'channel_id': '111222333444',
'session_id': 'existing-session-uuid',
'project_name': 'test-project',
'created_at': datetime.now(),
'last_active': datetime.now(),
'message_count': 5
}
bot.session_manager.get_session = AsyncMock(return_value=existing_session)
bot.session_manager.save_session = AsyncMock()
bot.session_manager.update_activity = AsyncMock()
bot.claude_runner.run = AsyncMock(return_value=mock_claude_response)
bot.response_formatter.format_response = MagicMock(return_value=["Response"])
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = MagicMock(id=987654321)
await bot._handle_claude_request(mock_discord_message, mock_project_config)
# Should call claude_runner with existing session_id
bot.claude_runner.run.assert_called_once()
call_kwargs = bot.claude_runner.run.call_args.kwargs
assert call_kwargs['session_id'] == 'existing-session-uuid'
@pytest.mark.asyncio
async def test_saves_session_after_successful_response(
self, mock_discord_message, mock_project_config, mock_claude_response
):
"""Test saves session to database after successful Claude response."""
bot = ClaudeCoordinator()
bot.session_manager.get_session = AsyncMock(return_value=None)
bot.session_manager.save_session = AsyncMock()
bot.session_manager.update_activity = AsyncMock()
bot.claude_runner.run = AsyncMock(return_value=mock_claude_response)
bot.response_formatter.format_response = MagicMock(return_value=["Response"])
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = MagicMock(id=987654321)
await bot._handle_claude_request(mock_discord_message, mock_project_config)
# Should save session with returned session_id
bot.session_manager.save_session.assert_called_once_with(
channel_id='111222333444',
session_id='test-session-uuid-1234',
project_name='test-project'
)
@pytest.mark.asyncio
async def test_updates_activity_after_message(
self, mock_discord_message, mock_project_config, mock_claude_response
):
"""Test updates session activity timestamp after processing message."""
bot = ClaudeCoordinator()
bot.session_manager.get_session = AsyncMock(return_value=None)
bot.session_manager.save_session = AsyncMock()
bot.session_manager.update_activity = AsyncMock()
bot.claude_runner.run = AsyncMock(return_value=mock_claude_response)
bot.response_formatter.format_response = MagicMock(return_value=["Response"])
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = MagicMock(id=987654321)
await bot._handle_claude_request(mock_discord_message, mock_project_config)
# Should update activity timestamp
bot.session_manager.update_activity.assert_called_once_with('111222333444')
class TestClaudeIntegration:
"""Tests for Claude CLI integration."""
@pytest.mark.asyncio
async def test_passes_project_config_to_claude(
self, mock_discord_message, mock_project_config, mock_claude_response
):
"""Test passes project configuration to ClaudeRunner."""
bot = ClaudeCoordinator()
bot.session_manager.get_session = AsyncMock(return_value=None)
bot.session_manager.save_session = AsyncMock()
bot.session_manager.update_activity = AsyncMock()
bot.claude_runner.run = AsyncMock(return_value=mock_claude_response)
bot.response_formatter.format_response = MagicMock(return_value=["Response"])
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = MagicMock(id=987654321)
await bot._handle_claude_request(mock_discord_message, mock_project_config)
# Verify all project config passed to claude_runner
call_kwargs = bot.claude_runner.run.call_args.kwargs
assert call_kwargs['cwd'] == '/tmp/test-project'
assert call_kwargs['allowed_tools'] == ['Bash', 'Read', 'Write']
assert call_kwargs['system_prompt'] == 'You are a test assistant.'
assert call_kwargs['model'] == 'sonnet'
@pytest.mark.asyncio
async def test_sends_claude_response_to_discord(
self, mock_discord_message, mock_project_config, mock_claude_response
):
"""Test sends Claude's response back to Discord channel."""
bot = ClaudeCoordinator()
bot.session_manager.get_session = AsyncMock(return_value=None)
bot.session_manager.save_session = AsyncMock()
bot.session_manager.update_activity = AsyncMock()
bot.claude_runner.run = AsyncMock(return_value=mock_claude_response)
bot.response_formatter.format_response = MagicMock(
return_value=["This is Claude's response to your message."]
)
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = MagicMock(id=987654321)
await bot._handle_claude_request(mock_discord_message, mock_project_config)
# Verify response sent to Discord channel
mock_discord_message.channel.send.assert_called_once_with(
"This is Claude's response to your message."
)
@pytest.mark.asyncio
async def test_sends_multiple_chunks_if_response_split(
self, mock_discord_message, mock_project_config, mock_claude_response
):
"""Test sends multiple messages if response formatter splits content."""
bot = ClaudeCoordinator()
bot.session_manager.get_session = AsyncMock(return_value=None)
bot.session_manager.save_session = AsyncMock()
bot.session_manager.update_activity = AsyncMock()
bot.claude_runner.run = AsyncMock(return_value=mock_claude_response)
bot.response_formatter.format_response = MagicMock(
return_value=["Chunk 1", "Chunk 2", "Chunk 3"]
)
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = MagicMock(id=987654321)
await bot._handle_claude_request(mock_discord_message, mock_project_config)
# Verify all chunks sent
assert mock_discord_message.channel.send.call_count == 3
calls = mock_discord_message.channel.send.call_args_list
assert calls[0][0][0] == "Chunk 1"
assert calls[1][0][0] == "Chunk 2"
assert calls[2][0][0] == "Chunk 3"
class TestErrorHandling:
"""Tests for error handling scenarios."""
@pytest.mark.asyncio
async def test_handles_empty_message(
self, mock_discord_message, mock_project_config, mock_bot_user
):
"""Test handles empty message gracefully."""
bot = ClaudeCoordinator()
mock_discord_message.content = f"<@{mock_bot_user.id}>" # Just mention, no content
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = mock_bot_user
await bot._handle_claude_request(mock_discord_message, mock_project_config)
# Should send error message
mock_discord_message.channel.send.assert_called_once()
error_msg = mock_discord_message.channel.send.call_args[0][0]
assert "Please provide a message" in error_msg
@pytest.mark.asyncio
async def test_handles_claude_failure(
self, mock_discord_message, mock_project_config
):
"""Test handles Claude CLI failure gracefully."""
bot = ClaudeCoordinator()
bot.session_manager.get_session = AsyncMock(return_value=None)
bot.session_manager.save_session = AsyncMock()
bot.claude_runner.run = AsyncMock(return_value=ClaudeResponse(
success=False,
result="",
error="Command failed: invalid syntax"
))
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = MagicMock(id=987654321)
await bot._handle_claude_request(mock_discord_message, mock_project_config)
# Should send error message to Discord
mock_discord_message.channel.send.assert_called_once()
error_msg = mock_discord_message.channel.send.call_args[0][0]
assert "Error running Claude" in error_msg
assert "invalid syntax" in error_msg
class TestTypingIndicator:
"""Tests for Discord typing indicator."""
@pytest.mark.asyncio
async def test_shows_typing_while_processing(
self, mock_discord_message, mock_project_config, mock_claude_response
):
"""Test shows typing indicator while processing Claude request."""
bot = ClaudeCoordinator()
bot.session_manager.get_session = AsyncMock(return_value=None)
bot.session_manager.save_session = AsyncMock()
bot.session_manager.update_activity = AsyncMock()
bot.claude_runner.run = AsyncMock(return_value=mock_claude_response)
bot.response_formatter.format_response = MagicMock(return_value=["Response"])
with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop:
mock_user_prop.return_value = MagicMock(id=987654321)
await bot._handle_claude_request(mock_discord_message, mock_project_config)
# Verify typing() context manager was used
mock_discord_message.channel.typing.assert_called_once()
mock_discord_message.channel.typing.return_value.__aenter__.assert_called_once()
mock_discord_message.channel.typing.return_value.__aexit__.assert_called_once()