ai-assistant-discord-bot/tests/test_bot.py
Claude Discord Bot e6983b56b9 Week 3 progress: Logging and testing complete (MED-001, MED-002, MED-005)
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>
2026-02-13 19:12:04 +00:00

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