208 lines
8.2 KiB
Python
208 lines
8.2 KiB
Python
"""Tests for centralized logging configuration module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import FrozenInstanceError
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import structlog
|
|
|
|
from noteflow.infrastructure.logging.config import (
|
|
LoggingConfig,
|
|
configure_logging,
|
|
create_renderer,
|
|
get_log_level,
|
|
get_logger,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Generator
|
|
from typing import TextIO
|
|
|
|
|
|
# Test constants
|
|
DEFAULT_LEVEL = "INFO"
|
|
DEBUG_LEVEL = "DEBUG"
|
|
WARNING_LEVEL = "WARNING"
|
|
ERROR_LEVEL = "ERROR"
|
|
CRITICAL_LEVEL = "CRITICAL"
|
|
INVALID_LEVEL = "INVALID"
|
|
|
|
|
|
class TestLoggingConfig:
|
|
"""Tests for LoggingConfig dataclass."""
|
|
|
|
def test_config_has_default_values(self) -> None:
|
|
"""LoggingConfig initializes with sensible defaults."""
|
|
config = LoggingConfig()
|
|
assert config.level == DEFAULT_LEVEL, "default level should be INFO"
|
|
assert config.json_file is None, "json_file should default to None"
|
|
assert config.enable_console is True, "console should be enabled by default"
|
|
assert config.log_format == "auto", "log_format should default to 'auto'"
|
|
assert config.enable_log_buffer is True, "log buffer should be enabled by default"
|
|
assert config.enable_otel_context is True, "OTEL context should be enabled by default"
|
|
assert config.enable_noteflow_context is True, "NoteFlow context should be enabled"
|
|
assert config.console_colors is True, "console colors should be enabled by default"
|
|
|
|
def test_config_accepts_custom_level(self) -> None:
|
|
"""LoggingConfig accepts custom log level."""
|
|
config = LoggingConfig(level=DEBUG_LEVEL)
|
|
assert config.level == DEBUG_LEVEL, "level should be DEBUG when specified"
|
|
|
|
def test_config_accepts_json_file_path(self, tmp_path: Path) -> None:
|
|
"""LoggingConfig accepts JSON file path."""
|
|
expected_path = tmp_path / "noteflow.json"
|
|
config = LoggingConfig(json_file=expected_path)
|
|
assert config.json_file == expected_path, "json_file should match provided path"
|
|
|
|
def test_config_is_frozen(self) -> None:
|
|
"""LoggingConfig is immutable (frozen dataclass)."""
|
|
config = LoggingConfig()
|
|
with pytest.raises(
|
|
FrozenInstanceError, match="cannot assign to field"
|
|
) as excinfo:
|
|
config.level = DEBUG_LEVEL
|
|
assert isinstance(excinfo.value, FrozenInstanceError)
|
|
|
|
|
|
class TestGetLogLevel:
|
|
"""Tests for get_log_level helper function."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("level_name", "expected"),
|
|
[
|
|
(DEBUG_LEVEL, logging.DEBUG),
|
|
(DEFAULT_LEVEL, logging.INFO),
|
|
(WARNING_LEVEL, logging.WARNING),
|
|
(ERROR_LEVEL, logging.ERROR),
|
|
(CRITICAL_LEVEL, logging.CRITICAL),
|
|
],
|
|
)
|
|
def test_converts_valid_level_names(self, level_name: str, expected: int) -> None:
|
|
"""get_log_level converts valid level names to constants."""
|
|
result = get_log_level(level_name)
|
|
assert result == expected, f"{level_name} should map to {expected}"
|
|
|
|
def test_handles_lowercase_level_names(self) -> None:
|
|
"""get_log_level handles lowercase level names."""
|
|
result = get_log_level("debug")
|
|
assert result == logging.DEBUG, "lowercase 'debug' should map to DEBUG"
|
|
|
|
def test_returns_info_for_invalid_level(self) -> None:
|
|
"""get_log_level returns INFO for invalid level names."""
|
|
result = get_log_level(INVALID_LEVEL)
|
|
assert result == logging.INFO, "invalid level should default to INFO"
|
|
|
|
|
|
class TestCreateRenderer:
|
|
"""Tests for create_renderer helper function."""
|
|
|
|
def test_returns_json_renderer_when_json_format_enabled(self) -> None:
|
|
"""create_renderer returns JSONRenderer when log_format is 'json'."""
|
|
config = LoggingConfig(log_format="json")
|
|
renderer = create_renderer(config)
|
|
assert isinstance(
|
|
renderer, structlog.processors.JSONRenderer
|
|
), "should return JSONRenderer when JSON console enabled"
|
|
|
|
def test_returns_json_renderer_when_not_tty(self) -> None:
|
|
"""create_renderer returns JSONRenderer when stderr is not a TTY."""
|
|
config = LoggingConfig(log_format="auto")
|
|
with patch("sys.stderr") as mock_stderr:
|
|
mock_stderr.isatty.return_value = False
|
|
renderer = create_renderer(config)
|
|
assert isinstance(
|
|
renderer, structlog.processors.JSONRenderer
|
|
), "should return JSONRenderer when not a TTY"
|
|
|
|
def test_returns_callable_renderer_for_tty(self) -> None:
|
|
"""create_renderer returns Rich handler renderer function for TTY."""
|
|
config = LoggingConfig(log_format="auto", console_colors=True)
|
|
with patch("sys.stderr") as mock_stderr:
|
|
mock_stderr.isatty.return_value = True
|
|
renderer = create_renderer(config)
|
|
# Implementation returns _render_for_rich_handler function for Rich console output
|
|
assert callable(renderer), "should return callable renderer for TTY"
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_root_logger() -> Generator[None, None, None]:
|
|
"""Clean up root logger handlers after test."""
|
|
root = logging.getLogger()
|
|
original_handlers = root.handlers[:]
|
|
original_level = root.level
|
|
yield
|
|
# Restore original state
|
|
root.handlers = original_handlers
|
|
root.setLevel(original_level)
|
|
|
|
|
|
@pytest.mark.usefixtures("clean_root_logger")
|
|
class TestConfigureLogging:
|
|
"""Tests for configure_logging function."""
|
|
|
|
def test_configures_with_default_config(self) -> None:
|
|
"""configure_logging works with default config."""
|
|
configure_logging()
|
|
root = logging.getLogger()
|
|
assert root.level == logging.INFO, "root logger should be set to INFO"
|
|
|
|
def test_configures_with_custom_level(self) -> None:
|
|
"""configure_logging accepts custom level parameter."""
|
|
configure_logging(level=DEBUG_LEVEL)
|
|
root = logging.getLogger()
|
|
assert root.level == logging.DEBUG, "root logger should be set to DEBUG"
|
|
|
|
def test_configures_with_config_object(self) -> None:
|
|
"""configure_logging accepts LoggingConfig object."""
|
|
config = LoggingConfig(level=WARNING_LEVEL, enable_console=False)
|
|
configure_logging(config)
|
|
root = logging.getLogger()
|
|
assert root.level == logging.WARNING, "root logger should be set to WARNING"
|
|
|
|
def test_clears_existing_handlers(self) -> None:
|
|
"""configure_logging clears existing handlers before adding new ones."""
|
|
root = logging.getLogger()
|
|
dummy_handler = logging.StreamHandler()
|
|
root.addHandler(dummy_handler)
|
|
|
|
configure_logging(config=LoggingConfig(enable_console=True, enable_log_buffer=False))
|
|
|
|
# Should have cleared old handlers and added console handler
|
|
assert (
|
|
dummy_handler not in root.handlers
|
|
), "existing handlers should be cleared"
|
|
|
|
def test_adds_console_handler_when_enabled(self) -> None:
|
|
"""configure_logging adds console handler when enabled."""
|
|
configure_logging(config=LoggingConfig(enable_console=True, enable_log_buffer=False))
|
|
root = logging.getLogger()
|
|
stream_handlers: list[logging.StreamHandler[TextIO]] = [
|
|
h for h in root.handlers if isinstance(h, logging.StreamHandler)
|
|
]
|
|
assert stream_handlers, "should add StreamHandler for console output"
|
|
|
|
|
|
@pytest.mark.usefixtures("clean_root_logger")
|
|
class TestGetLogger:
|
|
"""Tests for get_logger function."""
|
|
|
|
def test_returns_bound_logger(self) -> None:
|
|
"""get_logger returns a structlog logger with standard methods."""
|
|
configure_logging()
|
|
logger = get_logger("test.module")
|
|
# structlog returns BoundLoggerLazyProxy which wraps BoundLogger
|
|
assert hasattr(logger, "info"), "logger should have info method"
|
|
assert hasattr(logger, "debug"), "logger should have debug method"
|
|
assert hasattr(logger, "error"), "logger should have error method"
|
|
|
|
def test_returns_logger_without_name(self) -> None:
|
|
"""get_logger works without name argument."""
|
|
configure_logging()
|
|
logger = get_logger()
|
|
assert logger is not None, "should return logger without explicit name"
|