Files
musicseerr/backend/tests/test_config_validation.py
T
2026-04-03 15:53:00 +01:00

218 lines
8.2 KiB
Python

"""Tests for config validation hardening and log_level application (CONSOLIDATED-08)."""
import logging
from pathlib import Path
from unittest.mock import patch
import msgspec
import pytest
from pydantic import ValidationError as PydanticValidationError
from core.config import Settings
from core.exceptions import ConfigurationError
# Helpers
def _make_settings(**overrides) -> Settings:
"""Build a Settings with sensible defaults, applying overrides."""
defaults = {
"lidarr_url": "http://lidarr:8686",
"jellyfin_url": "http://jellyfin:8096",
"lidarr_api_key": "test-key",
"config_file_path": Path("/tmp/musicseerr-test-config.json"),
}
defaults.update(overrides)
return Settings(**defaults)
def _write_config(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "wb") as f:
f.write(msgspec.json.encode(data))
# A. Config Validation — Critical errors raise
class TestValidateConfigCriticalErrors:
def test_valid_config_creates_settings(self) -> None:
settings = _make_settings()
assert settings.lidarr_url == "http://lidarr:8686"
def test_invalid_url_scheme_raises(self) -> None:
with pytest.raises((ConfigurationError, PydanticValidationError)):
_make_settings(lidarr_url="ftp://lidarr:8686")
def test_invalid_jellyfin_url_scheme_raises(self) -> None:
with pytest.raises((ConfigurationError, PydanticValidationError)):
_make_settings(jellyfin_url="ftp://jellyfin:8096")
def test_missing_api_key_warns_but_does_not_raise(self, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
settings = _make_settings(lidarr_api_key="")
assert settings.lidarr_api_key == ""
assert any("LIDARR_API_KEY" in r.message for r in caplog.records)
def test_http_pool_mismatch_warns_but_does_not_raise(self, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
settings = _make_settings(http_max_connections=10, http_max_keepalive=20)
assert settings.http_max_connections == 10
assert any("http_max_connections" in r.message for r in caplog.records)
# B. log_level field validator
class TestLogLevelValidator:
@pytest.mark.parametrize("level", ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"])
def test_valid_levels_accepted(self, level: str) -> None:
settings = _make_settings(log_level=level)
assert settings.log_level == level
def test_normalises_to_uppercase(self) -> None:
settings = _make_settings(log_level="debug")
assert settings.log_level == "DEBUG"
def test_invalid_level_raises(self) -> None:
with pytest.raises(PydanticValidationError, match="(?i)invalid log_level"):
_make_settings(log_level="VERBOSE")
def test_mixed_case_normalised(self) -> None:
settings = _make_settings(log_level="Warning")
assert settings.log_level == "WARNING"
# C. load_from_file — type validation
class TestLoadFromFileTypeValidation:
def test_wrong_type_raises_configuration_error(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
_write_config(config_path, {"port": "banana"})
settings = _make_settings(config_file_path=config_path)
with pytest.raises(ConfigurationError, match="type errors"):
settings.load_from_file()
def test_coercible_type_succeeds(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
_write_config(config_path, {"port": "8688"})
settings = _make_settings(config_file_path=config_path)
settings.load_from_file()
assert settings.port == 8688
def test_unknown_key_warns(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
config_path = tmp_path / "config.json"
_write_config(config_path, {"totally_unknown_key": "value"})
settings = _make_settings(config_file_path=config_path)
with caplog.at_level(logging.WARNING):
settings.load_from_file()
assert any("Unknown config key" in r.message for r in caplog.records)
def test_invalid_url_in_file_raises(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
_write_config(config_path, {"lidarr_url": "ftp://bad-scheme:8686"})
settings = _make_settings(config_file_path=config_path)
with pytest.raises(ConfigurationError, match="(?i)critical configuration"):
settings.load_from_file()
def test_valid_config_file_loads(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
_write_config(config_path, {"port": 9999, "lidarr_api_key": "new-key"})
settings = _make_settings(config_file_path=config_path)
settings.load_from_file()
assert settings.port == 9999
assert settings.lidarr_api_key == "new-key"
def test_invalid_log_level_in_file_raises(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
_write_config(config_path, {"log_level": "verbose"})
settings = _make_settings(config_file_path=config_path)
with pytest.raises(ConfigurationError, match="(?i)invalid log_level"):
settings.load_from_file()
def test_log_level_normalised_through_file(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
_write_config(config_path, {"log_level": "debug"})
settings = _make_settings(config_file_path=config_path)
settings.load_from_file()
assert settings.log_level == "DEBUG"
def test_url_trailing_slash_stripped_through_file(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
_write_config(config_path, {"lidarr_url": "http://lidarr:8686/"})
settings = _make_settings(config_file_path=config_path)
settings.load_from_file()
assert settings.lidarr_url == "http://lidarr:8686"
def test_failed_load_does_not_partially_mutate(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
_write_config(config_path, {"port": 7777, "lidarr_url": "ftp://bad:1234"})
settings = _make_settings(config_file_path=config_path)
original_port = settings.port
with pytest.raises(ConfigurationError):
settings.load_from_file()
assert settings.port == original_port
# D. Log level application at startup
class TestLogLevelApplication:
def test_log_level_applied_to_root_logger(self) -> None:
settings = _make_settings(log_level="DEBUG")
configured_level = getattr(logging, settings.log_level, logging.INFO)
root = logging.getLogger()
original = root.level
try:
root.setLevel(configured_level)
assert root.getEffectiveLevel() == logging.DEBUG
finally:
root.setLevel(original)
def test_log_level_warning_applied(self) -> None:
settings = _make_settings(log_level="WARNING")
configured_level = getattr(logging, settings.log_level, logging.INFO)
root = logging.getLogger()
original = root.level
try:
root.setLevel(configured_level)
assert root.getEffectiveLevel() == logging.WARNING
finally:
root.setLevel(original)
# E. get_settings() cache safety
class TestGetSettingsCacheSafety:
def test_failed_load_does_not_poison_cache(self, tmp_path: Path) -> None:
import core.config as config_module
bad_path = tmp_path / "bad_config.json"
_write_config(bad_path, {"lidarr_url": "ftp://invalid"})
saved = config_module._settings
try:
config_module._settings = None
with patch.object(
Settings, "model_post_init", lambda self, _ctx: None
):
pass
with patch.dict(
"os.environ",
{"CONFIG_FILE_PATH": str(bad_path)},
):
with pytest.raises((ConfigurationError, Exception)):
config_module.get_settings()
assert config_module._settings is None
finally:
config_module._settings = saved