ai-assistant-discord-bot/tests/test_logging.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

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