From 9275747b6de235f95a3c9e3a7a21dcbbca3ee2ce Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 7 Feb 2026 18:24:32 +0100 Subject: [PATCH] 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 --- tests/unit/test_infrastructure_logger.py | 157 ++++++++++++++++++++ tests/unit/test_uvicorn_logging_config.py | 170 ++++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 tests/unit/test_infrastructure_logger.py create mode 100644 tests/unit/test_uvicorn_logging_config.py diff --git a/tests/unit/test_infrastructure_logger.py b/tests/unit/test_infrastructure_logger.py new file mode 100644 index 0000000..b531347 --- /dev/null +++ b/tests/unit/test_infrastructure_logger.py @@ -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) diff --git a/tests/unit/test_uvicorn_logging_config.py b/tests/unit/test_uvicorn_logging_config.py new file mode 100644 index 0000000..9f88c9a --- /dev/null +++ b/tests/unit/test_uvicorn_logging_config.py @@ -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)