Task 5: Add Infrastructure Logging tests (49 tests)
- test_infrastructure_logger.py: 21 tests for setup_logging (log levels, file creation, handlers, formatters, startup banner) and get_logger - test_uvicorn_logging_config.py: 28 tests for LOGGING_CONFIG structure, formatters, handlers, logger definitions, paths, and get_uvicorn_log_config
This commit is contained in:
157
tests/unit/test_infrastructure_logger.py
Normal file
157
tests/unit/test_infrastructure_logger.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Unit tests for the infrastructure logger module.
|
||||
|
||||
Tests setup_logging and get_logger from src/infrastructure/logging/logger.py.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.infrastructure.logging.logger import get_logger, setup_logging
|
||||
|
||||
|
||||
class TestSetupLogging:
|
||||
"""Tests for setup_logging function."""
|
||||
|
||||
def test_returns_logger_instance(self, tmp_path):
|
||||
"""setup_logging returns a logging.Logger."""
|
||||
logger = setup_logging(log_dir=tmp_path)
|
||||
assert isinstance(logger, logging.Logger)
|
||||
|
||||
def test_logger_name_is_aniworld(self, tmp_path):
|
||||
"""Returned logger has name 'aniworld'."""
|
||||
logger = setup_logging(log_dir=tmp_path)
|
||||
assert logger.name == "aniworld"
|
||||
|
||||
def test_creates_log_directory(self, tmp_path):
|
||||
"""Log directory is created if it does not exist."""
|
||||
log_dir = tmp_path / "new_logs"
|
||||
setup_logging(log_dir=log_dir)
|
||||
assert log_dir.is_dir()
|
||||
|
||||
def test_creates_log_file(self, tmp_path):
|
||||
"""A log file is created in the log directory."""
|
||||
setup_logging(log_dir=tmp_path)
|
||||
assert (tmp_path / "fastapi_app.log").exists()
|
||||
|
||||
def test_custom_log_file_name(self, tmp_path):
|
||||
"""Custom log file name is used."""
|
||||
setup_logging(log_file="custom.log", log_dir=tmp_path)
|
||||
assert (tmp_path / "custom.log").exists()
|
||||
|
||||
def test_default_log_level_is_info(self, tmp_path):
|
||||
"""Default log level falls back to INFO."""
|
||||
with patch("src.infrastructure.logging.logger.settings") as mock_settings:
|
||||
mock_settings.log_level = None
|
||||
logger = setup_logging(log_dir=tmp_path)
|
||||
assert logger.getEffectiveLevel() == logging.INFO
|
||||
|
||||
def test_custom_log_level_debug(self, tmp_path):
|
||||
"""Custom log level DEBUG is applied."""
|
||||
logger = setup_logging(log_level="DEBUG", log_dir=tmp_path)
|
||||
assert logger.getEffectiveLevel() == logging.DEBUG
|
||||
|
||||
def test_custom_log_level_warning(self, tmp_path):
|
||||
"""Custom log level WARNING is applied."""
|
||||
logger = setup_logging(log_level="WARNING", log_dir=tmp_path)
|
||||
assert logger.getEffectiveLevel() == logging.WARNING
|
||||
|
||||
def test_log_level_case_insensitive(self, tmp_path):
|
||||
"""Log level is case-insensitive."""
|
||||
logger = setup_logging(log_level="debug", log_dir=tmp_path)
|
||||
assert logger.getEffectiveLevel() == logging.DEBUG
|
||||
|
||||
def test_root_logger_has_two_handlers(self, tmp_path):
|
||||
"""Root logger gets exactly console + file handlers."""
|
||||
setup_logging(log_dir=tmp_path)
|
||||
root = logging.getLogger()
|
||||
# Should be at least 2 handlers (console + file)
|
||||
handler_types = {type(h).__name__ for h in root.handlers}
|
||||
assert "StreamHandler" in handler_types
|
||||
assert "FileHandler" in handler_types
|
||||
|
||||
def test_clears_existing_root_handlers(self, tmp_path):
|
||||
"""Existing root handlers are cleared to avoid duplicates."""
|
||||
root = logging.getLogger()
|
||||
root.addHandler(logging.StreamHandler())
|
||||
initial_count = len(root.handlers)
|
||||
setup_logging(log_dir=tmp_path)
|
||||
# After setup, should have exactly 2 (console + file)
|
||||
assert len(root.handlers) == 2
|
||||
|
||||
def test_writes_to_log_file(self, tmp_path):
|
||||
"""Messages are written to the log file."""
|
||||
logger = setup_logging(log_dir=tmp_path)
|
||||
logger.info("test message for file")
|
||||
# Flush handlers
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.flush()
|
||||
log_content = (tmp_path / "fastapi_app.log").read_text()
|
||||
assert "test message for file" in log_content
|
||||
|
||||
def test_log_format_includes_timestamp(self, tmp_path):
|
||||
"""File log entries include a timestamp."""
|
||||
logger = setup_logging(log_dir=tmp_path)
|
||||
logger.info("timestamp check")
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.flush()
|
||||
log_content = (tmp_path / "fastapi_app.log").read_text()
|
||||
# Detailed formatter includes date
|
||||
assert "-" in log_content # At minimum YYYY-MM-DD format
|
||||
|
||||
def test_log_format_includes_level_name(self, tmp_path):
|
||||
"""File log entries include the log level name."""
|
||||
logger = setup_logging(log_dir=tmp_path)
|
||||
logger.warning("level check")
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.flush()
|
||||
log_content = (tmp_path / "fastapi_app.log").read_text()
|
||||
assert "WARNING" in log_content
|
||||
|
||||
def test_startup_banner_logged(self, tmp_path):
|
||||
"""Startup banner with log config info is written."""
|
||||
setup_logging(log_dir=tmp_path)
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.flush()
|
||||
log_content = (tmp_path / "fastapi_app.log").read_text()
|
||||
assert "Logging configured successfully" in log_content
|
||||
|
||||
def test_level_from_settings(self, tmp_path):
|
||||
"""Falls back to settings.log_level when no explicit level."""
|
||||
with patch("src.infrastructure.logging.logger.settings") as mock_settings:
|
||||
mock_settings.log_level = "ERROR"
|
||||
logger = setup_logging(log_dir=tmp_path)
|
||||
assert logger.getEffectiveLevel() == logging.ERROR
|
||||
|
||||
def test_invalid_log_level_defaults_to_info(self, tmp_path):
|
||||
"""Invalid log level string falls back to INFO."""
|
||||
logger = setup_logging(log_level="INVALID_LEVEL", log_dir=tmp_path)
|
||||
assert logger.getEffectiveLevel() == logging.INFO
|
||||
|
||||
|
||||
class TestGetLogger:
|
||||
"""Tests for get_logger function."""
|
||||
|
||||
def test_returns_logger_with_given_name(self):
|
||||
"""get_logger returns a logger with the requested name."""
|
||||
logger = get_logger("my_module")
|
||||
assert logger.name == "my_module"
|
||||
|
||||
def test_returns_same_logger_for_same_name(self):
|
||||
"""Calling get_logger twice with same name returns same object."""
|
||||
a = get_logger("shared_name")
|
||||
b = get_logger("shared_name")
|
||||
assert a is b
|
||||
|
||||
def test_hierarchical_name(self):
|
||||
"""Dotted names produce hierarchical loggers."""
|
||||
parent = get_logger("app")
|
||||
child = get_logger("app.sub")
|
||||
assert child.parent is parent or child.parent.name == "app"
|
||||
|
||||
def test_logger_is_standard_logging(self):
|
||||
"""Returned object is a standard logging.Logger."""
|
||||
logger = get_logger("test_std")
|
||||
assert isinstance(logger, logging.Logger)
|
||||
170
tests/unit/test_uvicorn_logging_config.py
Normal file
170
tests/unit/test_uvicorn_logging_config.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Unit tests for the uvicorn logging configuration module.
|
||||
|
||||
Tests LOGGING_CONFIG dict and get_uvicorn_log_config from
|
||||
src/infrastructure/logging/uvicorn_config.py.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.config
|
||||
|
||||
import pytest
|
||||
|
||||
from src.infrastructure.logging.uvicorn_config import (
|
||||
LOGGING_CONFIG,
|
||||
LOG_FILE,
|
||||
LOGS_DIR,
|
||||
get_uvicorn_log_config,
|
||||
)
|
||||
|
||||
|
||||
class TestLoggingConfigStructure:
|
||||
"""Tests for the LOGGING_CONFIG dict schema."""
|
||||
|
||||
def test_version_is_one(self):
|
||||
"""Logging config version must be 1."""
|
||||
assert LOGGING_CONFIG["version"] == 1
|
||||
|
||||
def test_disable_existing_loggers_false(self):
|
||||
"""Existing loggers should not be disabled."""
|
||||
assert LOGGING_CONFIG["disable_existing_loggers"] is False
|
||||
|
||||
def test_has_formatters_section(self):
|
||||
"""Config contains formatters."""
|
||||
assert "formatters" in LOGGING_CONFIG
|
||||
assert len(LOGGING_CONFIG["formatters"]) >= 2
|
||||
|
||||
def test_has_handlers_section(self):
|
||||
"""Config contains handlers."""
|
||||
assert "handlers" in LOGGING_CONFIG
|
||||
assert "console" in LOGGING_CONFIG["handlers"]
|
||||
assert "file" in LOGGING_CONFIG["handlers"]
|
||||
|
||||
def test_has_loggers_section(self):
|
||||
"""Config contains logger definitions."""
|
||||
assert "loggers" in LOGGING_CONFIG
|
||||
|
||||
def test_has_root_section(self):
|
||||
"""Config has a root logger entry."""
|
||||
assert "root" in LOGGING_CONFIG
|
||||
assert "handlers" in LOGGING_CONFIG["root"]
|
||||
|
||||
|
||||
class TestFormatters:
|
||||
"""Tests for formatter definitions."""
|
||||
|
||||
def test_default_formatter_exists(self):
|
||||
"""Default formatter is defined."""
|
||||
assert "default" in LOGGING_CONFIG["formatters"]
|
||||
|
||||
def test_access_formatter_exists(self):
|
||||
"""Access formatter is defined."""
|
||||
assert "access" in LOGGING_CONFIG["formatters"]
|
||||
|
||||
def test_detailed_formatter_exists(self):
|
||||
"""Detailed formatter is defined for file output."""
|
||||
assert "detailed" in LOGGING_CONFIG["formatters"]
|
||||
|
||||
def test_detailed_formatter_has_datefmt(self):
|
||||
"""Detailed formatter includes date format."""
|
||||
assert "datefmt" in LOGGING_CONFIG["formatters"]["detailed"]
|
||||
|
||||
|
||||
class TestHandlers:
|
||||
"""Tests for handler definitions."""
|
||||
|
||||
def test_console_handler_streams_to_stdout(self):
|
||||
"""Console handler streams to stdout."""
|
||||
assert LOGGING_CONFIG["handlers"]["console"]["stream"] == "ext://sys.stdout"
|
||||
|
||||
def test_file_handler_uses_detailed_formatter(self):
|
||||
"""File handler uses the detailed formatter."""
|
||||
assert LOGGING_CONFIG["handlers"]["file"]["formatter"] == "detailed"
|
||||
|
||||
def test_file_handler_encoding_is_utf8(self):
|
||||
"""File handler writes UTF-8."""
|
||||
assert LOGGING_CONFIG["handlers"]["file"]["encoding"] == "utf-8"
|
||||
|
||||
def test_file_handler_mode_is_append(self):
|
||||
"""File handler appends to existing file."""
|
||||
assert LOGGING_CONFIG["handlers"]["file"]["mode"] == "a"
|
||||
|
||||
|
||||
class TestLoggerDefinitions:
|
||||
"""Tests for individual logger entries."""
|
||||
|
||||
def test_uvicorn_logger_defined(self):
|
||||
"""Main uvicorn logger is configured."""
|
||||
assert "uvicorn" in LOGGING_CONFIG["loggers"]
|
||||
|
||||
def test_uvicorn_error_logger_defined(self):
|
||||
"""uvicorn.error logger is configured."""
|
||||
assert "uvicorn.error" in LOGGING_CONFIG["loggers"]
|
||||
|
||||
def test_uvicorn_access_logger_defined(self):
|
||||
"""uvicorn.access logger is configured."""
|
||||
assert "uvicorn.access" in LOGGING_CONFIG["loggers"]
|
||||
|
||||
def test_aniworld_logger_defined(self):
|
||||
"""Application 'aniworld' logger is configured."""
|
||||
assert "aniworld" in LOGGING_CONFIG["loggers"]
|
||||
|
||||
def test_watchfiles_logger_suppressed(self):
|
||||
"""watchfiles.main logger level is WARNING to reduce noise."""
|
||||
wf = LOGGING_CONFIG["loggers"]["watchfiles.main"]
|
||||
assert wf["level"] == "WARNING"
|
||||
|
||||
def test_uvicorn_loggers_no_propagation(self):
|
||||
"""Uvicorn loggers do not propagate to root."""
|
||||
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
assert LOGGING_CONFIG["loggers"][name]["propagate"] is False
|
||||
|
||||
def test_all_loggers_have_handlers(self):
|
||||
"""Every logger definition specifies at least one handler."""
|
||||
for name, cfg in LOGGING_CONFIG["loggers"].items():
|
||||
assert "handlers" in cfg, f"Logger '{name}' missing handlers"
|
||||
assert len(cfg["handlers"]) >= 1
|
||||
|
||||
|
||||
class TestPaths:
|
||||
"""Tests for log path constants."""
|
||||
|
||||
def test_logs_dir_is_path(self):
|
||||
"""LOGS_DIR is a Path object."""
|
||||
from pathlib import Path
|
||||
assert isinstance(LOGS_DIR, Path)
|
||||
|
||||
def test_log_file_is_path(self):
|
||||
"""LOG_FILE is a Path object."""
|
||||
from pathlib import Path
|
||||
assert isinstance(LOG_FILE, Path)
|
||||
|
||||
def test_log_file_parent_is_logs_dir(self):
|
||||
"""LOG_FILE resides in LOGS_DIR."""
|
||||
assert LOG_FILE.parent == LOGS_DIR
|
||||
|
||||
def test_log_file_name(self):
|
||||
"""Default log file is fastapi_app.log."""
|
||||
assert LOG_FILE.name == "fastapi_app.log"
|
||||
|
||||
|
||||
class TestGetUvicornLogConfig:
|
||||
"""Tests for get_uvicorn_log_config function."""
|
||||
|
||||
def test_returns_dict(self):
|
||||
"""Returns a dictionary."""
|
||||
cfg = get_uvicorn_log_config()
|
||||
assert isinstance(cfg, dict)
|
||||
|
||||
def test_returns_same_config(self):
|
||||
"""Returns the module-level LOGGING_CONFIG constant."""
|
||||
assert get_uvicorn_log_config() is LOGGING_CONFIG
|
||||
|
||||
def test_config_is_valid_for_dictconfig(self, tmp_path, monkeypatch):
|
||||
"""Configuration is accepted by logging.config.dictConfig."""
|
||||
cfg = get_uvicorn_log_config()
|
||||
# Override the file handler filename to avoid side effects
|
||||
import copy
|
||||
test_cfg = copy.deepcopy(cfg)
|
||||
test_cfg["handlers"]["file"]["filename"] = str(tmp_path / "test.log")
|
||||
# Should not raise
|
||||
logging.config.dictConfig(test_cfg)
|
||||
Reference in New Issue
Block a user