Files
noteflow/tests/infrastructure/observability/test_logging_config.py
Travis Vasceannie b11633192a
Some checks failed
CI / test-python (push) Failing after 22m14s
CI / test-rust (push) Has been cancelled
CI / test-typescript (push) Has been cancelled
deps
2026-01-24 21:31:58 +00:00

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"