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>
456 lines
19 KiB
Python
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()
|