major-domo-v2/tests/test_utils_logging.py
Cal Corum e6a30af604 CLAUDE: SUCCESSFUL STARTUP - Discord Bot v2.0 fully operational
 **MAJOR MILESTONE**: Bot successfully starts and loads all commands

🔧 **Key Fixes Applied**:
- Fixed Pydantic configuration (SettingsConfigDict vs ConfigDict)
- Resolved duplicate logging with hybrid propagation approach
- Enhanced console logging with detailed format (function:line)
- Eliminated redundant .log file handler (kept console + JSON)
- Fixed Pylance type errors across views and modals
- Added newline termination to JSON logs for better tool compatibility
- Enabled league commands package in bot.py
- Enhanced command tree hashing for proper type support

📦 **New Components Added**:
- Complete views package (base.py, common.py, embeds.py, modals.py)
- League service and commands integration
- Comprehensive test coverage improvements
- Enhanced decorator functionality with proper signature preservation

🎯 **Architecture Improvements**:
- Hybrid logging: detailed console for dev + structured JSON for monitoring
- Type-safe command tree handling for future extensibility
- Proper optional parameter handling in Pydantic models
- Eliminated duplicate log messages while preserving third-party library logs

🚀 **Ready for Production**: Bot loads all command packages successfully with no errors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-16 07:36:47 -05:00

361 lines
12 KiB
Python

"""
Tests for enhanced logging utilities
Tests contextual logging, operation tracing, and Discord context management.
"""
import pytest
import time
from unittest.mock import Mock, patch
from typing import Dict, Any
from utils.logging import (
get_contextual_logger,
set_discord_context,
clear_context,
ContextualLogger,
JSONFormatter,
log_context
)
class TestContextualLogger:
"""Test contextual logger functionality."""
@pytest.fixture
def logger(self) -> ContextualLogger:
"""Create a test contextual logger."""
return get_contextual_logger('test_logger')
def test_start_operation(self, logger):
"""Test operation start tracking."""
trace_id = logger.start_operation('test_operation')
assert trace_id is not None
assert len(trace_id) == 8 # UUID truncated to 8 chars
assert logger._start_time is not None
# Check that context was set
context = log_context.get({})
assert 'trace_id' in context
assert context['trace_id'] == trace_id
assert context['operation'] == 'test_operation'
def test_start_operation_no_name(self, logger):
"""Test operation start without operation name."""
# Clear any existing context first
clear_context()
trace_id = logger.start_operation()
assert trace_id is not None
assert logger._start_time is not None
context = log_context.get({})
assert 'trace_id' in context
assert context['trace_id'] == trace_id
assert 'operation' not in context
def test_end_operation_success(self, logger):
"""Test successful operation end tracking."""
trace_id = logger.start_operation('test_operation')
time.sleep(0.01) # Small delay to ensure duration > 0
with patch.object(logger.logger, 'info') as mock_info:
logger.end_operation(trace_id, 'completed')
# Verify info was called with correct parameters
mock_info.assert_called_once()
call_args = mock_info.call_args
assert 'Operation completed' in call_args[0][0]
# Check extra parameters
extra = call_args[1]['extra']
assert 'trace_id' in extra
assert 'final_duration_ms' in extra
assert extra['final_duration_ms'] > 0
assert extra['operation_result'] == 'completed'
# Verify context was cleared
assert logger._start_time is None
def test_end_operation_without_start(self, logger):
"""Test end_operation called without start_operation."""
with patch.object(logger, 'warning') as mock_warning:
logger.end_operation('fake_trace_id', 'completed')
mock_warning.assert_called_once_with(
"end_operation called without corresponding start_operation"
)
def test_end_operation_clears_context(self, logger):
"""Test that end_operation properly clears context."""
trace_id = logger.start_operation('test_operation')
# Verify context is set
context_before = log_context.get({})
assert 'trace_id' in context_before
assert 'operation' in context_before
logger.end_operation(trace_id, 'completed')
# Verify context was cleared
context_after = log_context.get({})
assert 'trace_id' not in context_after or context_after.get('trace_id') != trace_id
assert 'operation' not in context_after
def test_duration_tracking(self, logger):
"""Test that duration is tracked correctly."""
logger.start_operation('test_operation')
time.sleep(0.01)
duration_ms = logger._get_duration_ms()
assert duration_ms is not None
assert duration_ms > 0
assert duration_ms < 1000 # Should be less than 1 second
def test_logging_methods_with_duration(self, logger):
"""Test that logging methods include duration when operation is active."""
trace_id = logger.start_operation('test_operation')
time.sleep(0.01)
with patch.object(logger.logger, 'info') as mock_info:
logger.info('test message', extra_param='value')
mock_info.assert_called_once()
call_args = mock_info.call_args
assert call_args[0][0] == 'test message'
extra = call_args[1]['extra']
assert 'duration_ms' in extra
assert extra['duration_ms'] > 0
assert extra['extra_param'] == 'value'
def test_error_logging_with_exception(self, logger):
"""Test error logging with exception object."""
logger.start_operation('test_operation')
test_exception = ValueError("Test error")
with patch.object(logger.logger, 'error') as mock_error:
logger.error('Error occurred', error=test_exception, context='test')
mock_error.assert_called_once()
call_args = mock_error.call_args
assert call_args[0][0] == 'Error occurred'
assert call_args[1]['exc_info'] is True
extra = call_args[1]['extra']
assert 'error' in extra
assert extra['error']['type'] == 'ValueError'
assert extra['error']['message'] == 'Test error'
assert extra['context'] == 'test'
def test_error_logging_without_exception(self, logger):
"""Test error logging without exception object."""
with patch.object(logger.logger, 'error') as mock_error:
logger.error('Error occurred', context='test')
mock_error.assert_called_once()
call_args = mock_error.call_args
assert call_args[0][0] == 'Error occurred'
assert 'exc_info' not in call_args[1]
extra = call_args[1]['extra']
assert 'error' not in extra
assert extra['context'] == 'test'
class TestDiscordContext:
"""Test Discord context management."""
def setUp(self):
"""Clear context before each test."""
clear_context()
def test_set_discord_context_with_interaction(self):
"""Test setting context from Discord interaction."""
# Mock interaction object
mock_interaction = Mock()
mock_interaction.user.id = 123456789
mock_interaction.guild.id = 987654321
mock_interaction.guild.name = "Test Guild"
mock_interaction.channel.id = 555666777
mock_interaction.command.name = "test"
set_discord_context(interaction=mock_interaction, command="/test")
context = log_context.get({})
assert context['user_id'] == '123456789'
assert context['guild_id'] == '987654321'
assert context['guild_name'] == "Test Guild"
assert context['channel_id'] == '555666777'
assert context['command'] == '/test'
def test_set_discord_context_explicit_params(self):
"""Test setting context with explicit parameters."""
set_discord_context(
user_id=123456789,
guild_id=987654321,
channel_id=555666777,
command='/explicit',
custom_field='custom_value'
)
context = log_context.get({})
assert context['user_id'] == '123456789'
assert context['guild_id'] == '987654321'
assert context['channel_id'] == '555666777'
assert context['command'] == '/explicit'
assert context['custom_field'] == 'custom_value'
def test_set_discord_context_override(self):
"""Test that explicit parameters override interaction values."""
mock_interaction = Mock()
mock_interaction.user.id = 111111111
mock_interaction.guild.id = 222222222
mock_interaction.channel.id = 333333333
set_discord_context(
interaction=mock_interaction,
user_id=999999999, # Override
command='/override'
)
context = log_context.get({})
assert context['user_id'] == '999999999' # Overridden value
assert context['guild_id'] == '222222222' # From interaction
assert context['command'] == '/override'
def test_clear_context(self):
"""Test context clearing."""
set_discord_context(user_id=123, command='/test')
# Verify context is set
context_before = log_context.get({})
assert len(context_before) > 0
clear_context()
# Verify context is cleared
context_after = log_context.get({})
assert len(context_after) == 0
class TestJSONFormatter:
"""Test JSON formatter functionality."""
@pytest.fixture
def formatter(self) -> JSONFormatter:
"""Create a JSON formatter instance."""
return JSONFormatter()
def test_json_formatter_basic(self, formatter):
"""Test basic JSON formatting."""
import logging
record = logging.LogRecord(
name='test_logger',
level=logging.INFO,
pathname='test.py',
lineno=10,
msg='Test message',
args=(),
exc_info=None
)
result = formatter.format(record)
# Should be valid JSON
import json
data = json.loads(result)
assert data['message'] == 'Test message'
assert data['level'] == 'INFO'
assert data['logger'] == 'test_logger'
assert 'timestamp' in data
def test_json_formatter_with_extra(self, formatter):
"""Test JSON formatting with extra fields."""
import logging
record = logging.LogRecord(
name='test_logger',
level=logging.ERROR,
pathname='test.py',
lineno=10,
msg='Error message',
args=(),
exc_info=None
)
# Add extra fields
record.user_id = '123456789'
record.trace_id = 'abc123'
record.duration_ms = 150
result = formatter.format(record)
import json
data = json.loads(result)
assert data['message'] == 'Error message'
assert data['level'] == 'ERROR'
# trace_id comes from context, duration_ms goes back to extra
assert 'extra' in data
assert data['extra']['user_id'] == '123456789'
assert data['extra']['trace_id'] == 'abc123' # This will be in extra since not set via context
assert data['extra']['duration_ms'] == 150
def test_json_formatter_with_context_trace_id(self, formatter):
"""Test JSON formatting with trace_id from context."""
import logging
from utils.logging import log_context
# Set trace_id in context
log_context.set({'trace_id': 'context123', 'operation': 'test_op'})
record = logging.LogRecord(
name='test_logger',
level=logging.INFO,
pathname='test.py',
lineno=15,
msg='Context message',
args=(),
exc_info=None
)
result = formatter.format(record)
import json
data = json.loads(result)
assert data['message'] == 'Context message'
assert data['level'] == 'INFO'
# trace_id should be promoted to standard key from context
assert data['trace_id'] == 'context123'
# context should still be present
assert 'context' in data
assert data['context']['trace_id'] == 'context123'
assert data['context']['operation'] == 'test_op'
# Clean up context
log_context.set({})
class TestLoggerFactory:
"""Test logger factory functions."""
def test_get_contextual_logger(self):
"""Test contextual logger factory."""
logger = get_contextual_logger('test.module')
assert isinstance(logger, ContextualLogger)
assert logger.logger.name == 'test.module'
def test_get_contextual_logger_unique_instances(self):
"""Test that each call returns a new instance."""
logger1 = get_contextual_logger('test1')
logger2 = get_contextual_logger('test2')
assert logger1 is not logger2
assert logger1.logger.name == 'test1'
assert logger2.logger.name == 'test2'