""" 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'