""" 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 "Claude Error" in error_msg # Error ID should be included for support reference assert "Error ID" in error_msg class TestTypingIndicatorLifecycle: """Test typing indicator starts, maintains, and stops correctly.""" @pytest.mark.asyncio async def test_typing_starts_on_request_begin( self, mock_discord_message, mock_project_config ): """ Test typing indicator starts when Claude request begins. Verifies that _maintain_typing is called and enters typing state immediately when processing a message. """ bot = ClaudeCoordinator() bot.session_manager.get_session = AsyncMock(return_value=None) bot.session_manager.save_session = AsyncMock() bot.session_manager.update_activity = AsyncMock() typing_started = [] original_typing = mock_discord_message.channel.typing def track_typing(): typing_started.append(True) return original_typing() mock_discord_message.channel.typing = track_typing # Mock quick Claude response (< 8 seconds) async def quick_response(*args, **kwargs): await asyncio.sleep(0.1) return ClaudeResponse( success=True, result="Quick response", session_id="test-session", cost=0.001, duration_ms=100, permission_denials=[] ) bot.claude_runner.run = AsyncMock(side_effect=quick_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() was called at least once assert len(typing_started) >= 1 @pytest.mark.asyncio async def test_typing_loops_for_long_operations( self, mock_discord_message, mock_project_config ): """ Test typing indicator loops for operations longer than 10 seconds. Simulates a 20-second Claude operation and verifies typing indicator is triggered multiple times (approximately every 8 seconds). """ bot = ClaudeCoordinator() bot.session_manager.get_session = AsyncMock(return_value=None) bot.session_manager.save_session = AsyncMock() bot.session_manager.update_activity = AsyncMock() typing_count = [] original_typing = mock_discord_message.channel.typing def track_typing(): typing_count.append(True) return original_typing() mock_discord_message.channel.typing = track_typing # Mock long Claude response (20 seconds) async def long_response(*args, **kwargs): await asyncio.sleep(20) return ClaudeResponse( success=True, result="Long response", session_id="test-session", cost=0.001, duration_ms=20000, permission_denials=[] ) bot.claude_runner.run = AsyncMock(side_effect=long_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() was called multiple times (should be ~3 times for 20s operation) # We expect at least 2 typing cycles for a 20 second operation assert len(typing_count) >= 2 @pytest.mark.asyncio async def test_typing_stops_on_successful_completion( self, mock_discord_message, mock_project_config ): """ Test typing indicator stops when Claude response arrives. Verifies that typing task is properly cancelled when response completes successfully. """ bot = ClaudeCoordinator() bot.session_manager.get_session = AsyncMock(return_value=None) bot.session_manager.save_session = AsyncMock() bot.session_manager.update_activity = AsyncMock() async def response(*args, **kwargs): await asyncio.sleep(0.1) return ClaudeResponse( success=True, result="Response", session_id="test-session", cost=0.001, duration_ms=100, permission_denials=[] ) bot.claude_runner.run = AsyncMock(side_effect=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) # This should complete without hanging await bot._handle_claude_request(mock_discord_message, mock_project_config) # If we get here, typing was properly stopped (no hang) assert True @pytest.mark.asyncio async def test_typing_stops_on_error( self, mock_discord_message, mock_project_config ): """ Test typing indicator stops when error occurs. Verifies that typing task is properly cleaned up in finally block even when Claude returns an error response. """ bot = ClaudeCoordinator() bot.session_manager.get_session = AsyncMock(return_value=None) async def error_response(*args, **kwargs): await asyncio.sleep(0.1) return ClaudeResponse( success=False, result="", error="Claude error occurred" ) bot.claude_runner.run = AsyncMock(side_effect=error_response) with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop: mock_user_prop.return_value = MagicMock(id=987654321) # This should complete without hanging await bot._handle_claude_request(mock_discord_message, mock_project_config) # If we get here, typing was properly stopped assert True @pytest.mark.asyncio async def test_typing_stops_on_timeout( self, mock_discord_message, mock_project_config ): """ Test typing indicator stops when timeout occurs. Verifies that typing task is properly cleaned up when an asyncio.TimeoutError is raised. """ bot = ClaudeCoordinator() bot.session_manager.get_session = AsyncMock(return_value=None) async def timeout_response(*args, **kwargs): await asyncio.sleep(0.1) raise asyncio.TimeoutError("Operation timed out") bot.claude_runner.run = AsyncMock(side_effect=timeout_response) with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop: mock_user_prop.return_value = MagicMock(id=987654321) # This should complete without hanging await bot._handle_claude_request(mock_discord_message, mock_project_config) # Verify timeout error message was sent assert mock_discord_message.channel.send.called error_msg = str(mock_discord_message.channel.send.call_args) assert "Timeout" in error_msg @pytest.mark.asyncio async def test_typing_stops_on_exception( self, mock_discord_message, mock_project_config ): """ Test typing indicator stops when unexpected exception occurs. Verifies typing cleanup happens in finally block for any exception type. """ bot = ClaudeCoordinator() bot.session_manager.get_session = AsyncMock(return_value=None) async def exception_response(*args, **kwargs): await asyncio.sleep(0.1) raise ValueError("Unexpected error") bot.claude_runner.run = AsyncMock(side_effect=exception_response) with patch.object(type(bot), 'user', new_callable=PropertyMock) as mock_user_prop: mock_user_prop.return_value = MagicMock(id=987654321) # This should complete without hanging await bot._handle_claude_request(mock_discord_message, mock_project_config) # If we get here, typing was properly stopped assert True class TestMaintainTypingMethod: """Test the _maintain_typing helper method directly.""" @pytest.mark.asyncio async def test_maintain_typing_loops_until_stopped(self): """ Test _maintain_typing continues looping until stop event is set. Verifies the method enters typing multiple times when stop_event isn't set for an extended period. """ bot = ClaudeCoordinator() mock_channel = MagicMock() typing_count = [] async def track_typing_enter(self): typing_count.append(1) return None async def track_typing_exit(self, exc_type, exc_val, exc_tb): return None typing_context = MagicMock() typing_context.__aenter__ = track_typing_enter typing_context.__aexit__ = track_typing_exit mock_channel.typing = MagicMock(return_value=typing_context) stop_event = asyncio.Event() # Start typing task and let it run for ~12 seconds typing_task = asyncio.create_task( bot._maintain_typing(mock_channel, stop_event) ) await asyncio.sleep(12) stop_event.set() await typing_task # Should have entered typing at least twice (at 0s and 8s) assert len(typing_count) >= 2 @pytest.mark.asyncio async def test_maintain_typing_stops_immediately_on_event(self): """ Test _maintain_typing stops immediately when event is set. Verifies that stop_event.wait() responds quickly to the event rather than waiting for the full 8 second timeout. """ bot = ClaudeCoordinator() mock_channel = MagicMock() async def mock_enter(self): return None async def mock_exit(self, exc_type, exc_val, exc_tb): return None typing_context = MagicMock() typing_context.__aenter__ = mock_enter typing_context.__aexit__ = mock_exit mock_channel.typing = MagicMock(return_value=typing_context) stop_event = asyncio.Event() typing_task = asyncio.create_task( bot._maintain_typing(mock_channel, stop_event) ) # Let it start typing await asyncio.sleep(0.1) # Set stop event - should stop quickly start_time = asyncio.get_event_loop().time() stop_event.set() await typing_task end_time = asyncio.get_event_loop().time() # Should stop in well under 8 seconds (use 2s margin for CI) assert (end_time - start_time) < 2.0