""" 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()