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>
357 lines
12 KiB
Python
357 lines
12 KiB
Python
"""Tests for structured logging configuration and error tracking.
|
|
|
|
Tests cover JSON formatting, log rotation, error ID generation,
|
|
Discord error message formatting, and privacy protection.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from claude_coordinator.logging_config import (
|
|
JSONFormatter,
|
|
ErrorTracker,
|
|
setup_logging,
|
|
get_log_directory,
|
|
format_error_for_discord,
|
|
log_and_format_error
|
|
)
|
|
|
|
|
|
class TestJSONFormatter:
|
|
"""Tests for JSONFormatter class."""
|
|
|
|
def test_json_formatter_produces_valid_json(self):
|
|
"""Test that JSONFormatter outputs valid JSON.
|
|
|
|
Validates that the formatter produces parseable JSON with
|
|
correct structure and no syntax errors.
|
|
"""
|
|
formatter = JSONFormatter()
|
|
record = logging.LogRecord(
|
|
name='test',
|
|
level=logging.INFO,
|
|
pathname='/test/path.py',
|
|
lineno=42,
|
|
msg='Test message',
|
|
args=(),
|
|
exc_info=None
|
|
)
|
|
|
|
output = formatter.format(record)
|
|
|
|
# Should parse as valid JSON
|
|
parsed = json.loads(output)
|
|
assert isinstance(parsed, dict)
|
|
|
|
def test_json_formatter_includes_required_fields(self):
|
|
"""Test that JSON logs include all required fields.
|
|
|
|
Validates that timestamp, level, module, function, line,
|
|
and message are present in every log entry.
|
|
"""
|
|
formatter = JSONFormatter()
|
|
record = logging.LogRecord(
|
|
name='test',
|
|
level=logging.ERROR,
|
|
pathname='/test/path.py',
|
|
lineno=123,
|
|
msg='Error message',
|
|
args=(),
|
|
exc_info=None
|
|
)
|
|
|
|
output = formatter.format(record)
|
|
parsed = json.loads(output)
|
|
|
|
# Check required fields
|
|
assert 'timestamp' in parsed
|
|
assert 'level' in parsed
|
|
assert parsed['level'] == 'ERROR'
|
|
assert 'module' in parsed
|
|
assert 'function' in parsed
|
|
assert 'line' in parsed
|
|
assert parsed['line'] == 123
|
|
assert 'message' in parsed
|
|
assert parsed['message'] == 'Error message'
|
|
|
|
def test_json_formatter_includes_exception_info(self):
|
|
"""Test that exceptions are properly included in JSON logs.
|
|
|
|
Validates that exc_info is formatted and included when present.
|
|
"""
|
|
formatter = JSONFormatter()
|
|
|
|
try:
|
|
raise ValueError("Test exception")
|
|
except ValueError:
|
|
import sys
|
|
exc_info = sys.exc_info()
|
|
|
|
record = logging.LogRecord(
|
|
name='test',
|
|
level=logging.ERROR,
|
|
pathname='/test/path.py',
|
|
lineno=456,
|
|
msg='Exception occurred',
|
|
args=(),
|
|
exc_info=exc_info
|
|
)
|
|
|
|
output = formatter.format(record)
|
|
parsed = json.loads(output)
|
|
|
|
# Should include exception traceback
|
|
assert 'exception' in parsed
|
|
assert 'ValueError' in parsed['exception']
|
|
assert 'Test exception' in parsed['exception']
|
|
|
|
def test_json_formatter_includes_extra_fields(self):
|
|
"""Test that extra fields are included in JSON output.
|
|
|
|
Validates that additional context passed via logging.extra
|
|
is properly included in the output.
|
|
"""
|
|
formatter = JSONFormatter()
|
|
record = logging.LogRecord(
|
|
name='test',
|
|
level=logging.INFO,
|
|
pathname='/test/path.py',
|
|
lineno=789,
|
|
msg='Test with extra',
|
|
args=(),
|
|
exc_info=None
|
|
)
|
|
|
|
# Add extra fields
|
|
record.channel_id = '12345'
|
|
record.session_id = 'abc-def-ghi'
|
|
record.cost_usd = 0.05
|
|
|
|
output = formatter.format(record)
|
|
parsed = json.loads(output)
|
|
|
|
# Extra fields should be present
|
|
assert parsed['channel_id'] == '12345'
|
|
assert parsed['session_id'] == 'abc-def-ghi'
|
|
assert parsed['cost_usd'] == 0.05
|
|
|
|
|
|
class TestErrorTracker:
|
|
"""Tests for ErrorTracker class."""
|
|
|
|
def test_generate_error_id_returns_string(self):
|
|
"""Test that error ID generation returns a string.
|
|
|
|
Validates basic return type and format of error IDs.
|
|
"""
|
|
error_id = ErrorTracker.generate_error_id()
|
|
assert isinstance(error_id, str)
|
|
assert len(error_id) == 8 # First 8 chars of UUID
|
|
|
|
def test_generate_error_id_is_unique(self):
|
|
"""Test that error IDs are unique across calls.
|
|
|
|
Validates that multiple calls produce different IDs.
|
|
"""
|
|
ids = [ErrorTracker.generate_error_id() for _ in range(100)]
|
|
# All IDs should be unique
|
|
assert len(ids) == len(set(ids))
|
|
|
|
def test_log_error_with_id_includes_error_id(self):
|
|
"""Test that error logging includes the error ID in extra fields.
|
|
|
|
Validates that the generated error ID is properly attached
|
|
to the log record for tracking.
|
|
"""
|
|
logger = logging.getLogger('test')
|
|
|
|
with patch.object(logger, 'log') as mock_log:
|
|
error_id = ErrorTracker.log_error_with_id(
|
|
logger,
|
|
logging.ERROR,
|
|
"Test error",
|
|
channel_id="12345"
|
|
)
|
|
|
|
# Should have called log with error_id in extra
|
|
mock_log.assert_called_once()
|
|
call_kwargs = mock_log.call_args[1]
|
|
assert 'extra' in call_kwargs
|
|
assert call_kwargs['extra']['error_id'] == error_id
|
|
assert call_kwargs['extra']['channel_id'] == "12345"
|
|
|
|
|
|
class TestSetupLogging:
|
|
"""Tests for setup_logging function."""
|
|
|
|
def test_setup_logging_creates_log_directory(self):
|
|
"""Test that log directory is created if it doesn't exist.
|
|
|
|
Validates directory creation for log file storage.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
log_file = Path(tmpdir) / "subdir" / "test.log"
|
|
|
|
setup_logging(
|
|
log_level="INFO",
|
|
log_file=str(log_file),
|
|
json_logs=True
|
|
)
|
|
|
|
# Directory should be created
|
|
assert log_file.parent.exists()
|
|
|
|
def test_setup_logging_configures_handlers(self):
|
|
"""Test that logging handlers are properly configured.
|
|
|
|
Validates that both file and console handlers are set up.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
log_file = Path(tmpdir) / "test.log"
|
|
|
|
setup_logging(
|
|
log_level="DEBUG",
|
|
log_file=str(log_file),
|
|
json_logs=True
|
|
)
|
|
|
|
root_logger = logging.getLogger()
|
|
|
|
# Should have handlers
|
|
assert len(root_logger.handlers) >= 2
|
|
|
|
# Check for RotatingFileHandler
|
|
handler_types = [type(h).__name__ for h in root_logger.handlers]
|
|
assert 'RotatingFileHandler' in handler_types
|
|
assert 'StreamHandler' in handler_types
|
|
|
|
|
|
class TestFormatErrorForDiscord:
|
|
"""Tests for Discord error message formatting."""
|
|
|
|
def test_format_error_includes_error_id(self):
|
|
"""Test that Discord error messages include error ID.
|
|
|
|
Validates that error ID is displayed for user reference.
|
|
"""
|
|
error_id = "abc12345"
|
|
msg = format_error_for_discord(
|
|
error_type="timeout",
|
|
error_id=error_id
|
|
)
|
|
|
|
assert error_id in msg
|
|
assert "Error ID" in msg
|
|
|
|
def test_format_error_handles_known_types(self):
|
|
"""Test that known error types produce appropriate messages.
|
|
|
|
Validates that different error types have user-friendly messages.
|
|
"""
|
|
# Test timeout error
|
|
msg = format_error_for_discord(error_type="timeout")
|
|
assert "⏱️" in msg
|
|
assert "Timeout" in msg
|
|
|
|
# Test Claude error
|
|
msg = format_error_for_discord(error_type="claude_error")
|
|
assert "❌" in msg
|
|
assert "Claude Error" in msg
|
|
|
|
# Test config error
|
|
msg = format_error_for_discord(error_type="config_error")
|
|
assert "⚙️" in msg
|
|
assert "Configuration Error" in msg
|
|
|
|
def test_format_error_handles_unknown_types(self):
|
|
"""Test that unknown error types produce generic message.
|
|
|
|
Validates fallback behavior for unexpected error types.
|
|
"""
|
|
msg = format_error_for_discord(error_type="unknown_type_xyz")
|
|
assert "❌" in msg
|
|
assert "Error" in msg
|
|
|
|
def test_format_error_does_not_expose_internal_details(self):
|
|
"""Test that error messages don't expose sensitive information.
|
|
|
|
Validates privacy protection - no stack traces, paths, or
|
|
internal implementation details in Discord messages.
|
|
"""
|
|
# Create exception with stack trace
|
|
try:
|
|
raise ValueError("Internal error details")
|
|
except ValueError as e:
|
|
msg = format_error_for_discord(
|
|
error_type="claude_error",
|
|
error=e,
|
|
error_id="test123"
|
|
)
|
|
|
|
# Should NOT contain exception details or stack traces
|
|
assert "ValueError" not in msg
|
|
assert "Internal error details" not in msg
|
|
assert "Traceback" not in msg
|
|
|
|
# Should contain user-friendly message
|
|
assert "❌" in msg
|
|
assert "Error ID" in msg
|
|
|
|
|
|
class TestLogAndFormatError:
|
|
"""Tests for log_and_format_error convenience function."""
|
|
|
|
def test_log_and_format_error_returns_tuple(self):
|
|
"""Test that function returns (error_id, discord_message) tuple.
|
|
|
|
Validates return value structure for use in error handling.
|
|
"""
|
|
logger = logging.getLogger('test')
|
|
|
|
with patch.object(logger, 'log'):
|
|
error_id, discord_msg = log_and_format_error(
|
|
logger,
|
|
error_type="timeout",
|
|
message="Test timeout",
|
|
channel_id="12345"
|
|
)
|
|
|
|
assert isinstance(error_id, str)
|
|
assert isinstance(discord_msg, str)
|
|
assert len(error_id) == 8
|
|
assert error_id in discord_msg
|
|
|
|
def test_log_and_format_error_logs_with_context(self):
|
|
"""Test that error is logged with full context.
|
|
|
|
Validates that all context fields are passed to logger.
|
|
"""
|
|
logger = logging.getLogger('test')
|
|
|
|
with patch.object(logger, 'log') as mock_log:
|
|
error_id, discord_msg = log_and_format_error(
|
|
logger,
|
|
error_type="timeout",
|
|
message="Test timeout",
|
|
channel_id="12345",
|
|
session_id="abc-def",
|
|
duration=300
|
|
)
|
|
|
|
# Should log with all context
|
|
mock_log.assert_called_once()
|
|
call_kwargs = mock_log.call_args[1]
|
|
assert call_kwargs['extra']['channel_id'] == "12345"
|
|
assert call_kwargs['extra']['session_id'] == "abc-def"
|
|
assert call_kwargs['extra']['duration'] == 300
|
|
assert call_kwargs['extra']['error_id'] == error_id
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|