MED-001: Enhanced typing indicator - Persistent typing loop (_maintain_typing method) - Loops every 8s to maintain indicator for long operations (30s-5min) - 8 comprehensive tests covering all lifecycle scenarios - 27/27 bot tests passing MED-002: Structured logging and error reporting - logging_config.py (371 lines) - JSONFormatter, ErrorTracker, format_error_for_discord - RotatingFileHandler (10MB max, 5 backups) - Unique 8-char error IDs for support tracking - Privacy-safe Discord error messages (7 error types) - Enhanced bot.py with structured logging throughout - 15/15 logging tests passing MED-005: Comprehensive test suite - Total: 156/157 tests passing (99.4%) - test_session_manager.py: 27 tests - test_claude_runner.py: 11 tests - test_config.py: 25 tests - test_response_formatter.py: 26 tests - test_bot.py: 27 tests - test_commands.py: 18 tests - test_concurrency.py: 7 tests - test_logging.py: 15 tests Total: 13/18 tasks complete (72.2%) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
738 lines
29 KiB
Python
738 lines
29 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 "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
|