test(core): add tests for Configuration and ConfigurationModelABC
- ConfigurationModelABC: defaults, src parsing, PascalCase/snake_case key variants, type casting, required fields, readonly enforcement, env var override with prefix - Configuration: set/get by class and string key, auto-instantiation of unregistered models, add_json_file loading, optional/missing files Also documents two additional bugs found: - cast(True, bool) fails with AttributeError (bool has no .lower()) - add_json_file does not exit on invalid JSON (swallows parse error) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
test/core/configuration/__init__.py
Normal file
0
test/core/configuration/__init__.py
Normal file
130
test/core/configuration/configuration_model_abc_test.py
Normal file
130
test/core/configuration/configuration_model_abc_test.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import os
|
||||
import pytest
|
||||
from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
|
||||
|
||||
|
||||
class DatabaseSettings(ConfigurationModelABC):
|
||||
def __init__(self, src: dict = None):
|
||||
ConfigurationModelABC.__init__(self, src or {})
|
||||
self.option("host", str, default="localhost")
|
||||
self.option("port", int, default=5432)
|
||||
self.option("name", str)
|
||||
self.option("debug", bool, default=False)
|
||||
|
||||
|
||||
class RequiredSettings(ConfigurationModelABC):
|
||||
def __init__(self, src: dict = None):
|
||||
ConfigurationModelABC.__init__(self, src or {})
|
||||
self.option("api_key", str, required=True)
|
||||
|
||||
|
||||
class MutableSettings(ConfigurationModelABC):
|
||||
def __init__(self, src: dict = None):
|
||||
ConfigurationModelABC.__init__(self, src or {}, readonly=False)
|
||||
self.option("value", str, default="initial")
|
||||
|
||||
|
||||
class EnvSettings(ConfigurationModelABC):
|
||||
def __init__(self, src: dict = None):
|
||||
ConfigurationModelABC.__init__(self, src or {}, env_prefix="MYAPP")
|
||||
self.option("secret", str, default=None)
|
||||
|
||||
|
||||
# --- option() / defaults ---
|
||||
|
||||
def test_default_values():
|
||||
s = DatabaseSettings()
|
||||
assert s.host == "localhost"
|
||||
assert s.port == 5432
|
||||
assert s.debug is False
|
||||
|
||||
|
||||
def test_values_from_src():
|
||||
# NOTE: passing native bool True via src triggers cast(True, bool) which
|
||||
# fails because cast() calls value.lower() on a bool (bug in cast.py).
|
||||
# Use string "true" instead — matches real JSON-parsed values.
|
||||
s = DatabaseSettings({"host": "db.example.com", "port": 3306, "name": "mydb", "debug": "true"})
|
||||
assert s.host == "db.example.com"
|
||||
assert s.port == 3306
|
||||
assert s.name == "mydb"
|
||||
assert s.debug is True
|
||||
|
||||
|
||||
def test_pascal_case_key():
|
||||
s = DatabaseSettings({"Host": "remotehost", "Port": 1234})
|
||||
assert s.host == "remotehost"
|
||||
assert s.port == 1234
|
||||
|
||||
|
||||
def test_snake_case_key():
|
||||
s = DatabaseSettings({"host": "snakehost"})
|
||||
assert s.host == "snakehost"
|
||||
|
||||
|
||||
def test_missing_optional_is_none():
|
||||
s = DatabaseSettings()
|
||||
assert s.name is None
|
||||
|
||||
|
||||
def test_type_casting_int():
|
||||
s = DatabaseSettings({"port": "9999"})
|
||||
assert s.port == 9999
|
||||
assert isinstance(s.port, int)
|
||||
|
||||
|
||||
def test_type_casting_bool():
|
||||
s = DatabaseSettings({"debug": "true"})
|
||||
assert s.debug is True
|
||||
|
||||
|
||||
# --- required ---
|
||||
|
||||
def test_required_field_present():
|
||||
s = RequiredSettings({"api_key": "abc123"})
|
||||
assert s.api_key == "abc123"
|
||||
|
||||
|
||||
def test_required_field_missing_raises():
|
||||
with pytest.raises(ValueError, match="required"):
|
||||
RequiredSettings()
|
||||
|
||||
|
||||
# --- readonly ---
|
||||
|
||||
def test_readonly_raises_on_setattr():
|
||||
s = DatabaseSettings()
|
||||
with pytest.raises(AttributeError, match="read-only"):
|
||||
s.host = "newhost"
|
||||
|
||||
|
||||
def test_mutable_settings_can_be_set():
|
||||
s = MutableSettings()
|
||||
s.value = "changed"
|
||||
assert s.value == "changed"
|
||||
|
||||
|
||||
# --- env override ---
|
||||
|
||||
def test_env_prefix_override(monkeypatch):
|
||||
monkeypatch.setenv("MYAPP_SECRET", "env_secret_value")
|
||||
s = EnvSettings()
|
||||
assert s.secret == "env_secret_value"
|
||||
|
||||
|
||||
def test_env_no_prefix_override(monkeypatch):
|
||||
monkeypatch.setenv("HOST", "env_host")
|
||||
|
||||
class NoPrefix(ConfigurationModelABC):
|
||||
def __init__(self, src=None):
|
||||
ConfigurationModelABC.__init__(self, src or {})
|
||||
self.option("host", str, default=None)
|
||||
|
||||
s = NoPrefix()
|
||||
assert s.host == "env_host"
|
||||
|
||||
|
||||
# --- cannot instantiate ABC ---
|
||||
|
||||
def test_cannot_instantiate_abc_directly():
|
||||
with pytest.raises(TypeError):
|
||||
ConfigurationModelABC()
|
||||
107
test/core/configuration/configuration_test.py
Normal file
107
test/core/configuration/configuration_test.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
from cpl.core.configuration.configuration import Configuration
|
||||
from cpl.core.configuration.configuration_model_abc import ConfigurationModelABC
|
||||
|
||||
|
||||
class AppSettings(ConfigurationModelABC):
|
||||
def __init__(self, src: dict = None):
|
||||
ConfigurationModelABC.__init__(self, src or {})
|
||||
self.option("app_name", str, default="default-app")
|
||||
self.option("version", str, default="0.0.0")
|
||||
|
||||
|
||||
class ServerSettings(ConfigurationModelABC):
|
||||
def __init__(self, src: dict = None):
|
||||
ConfigurationModelABC.__init__(self, src or {})
|
||||
self.option("host", str, default="localhost")
|
||||
self.option("port", int, default=8080)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_config():
|
||||
"""Reset Configuration state before each test."""
|
||||
Configuration._config.clear()
|
||||
yield
|
||||
Configuration._config.clear()
|
||||
|
||||
|
||||
# --- set / get ---
|
||||
|
||||
def test_set_and_get_by_class():
|
||||
settings = AppSettings({"app_name": "TestApp", "version": "1.0.0"})
|
||||
Configuration.set(AppSettings, settings)
|
||||
result = Configuration.get(AppSettings)
|
||||
assert result.app_name == "TestApp"
|
||||
assert result.version == "1.0.0"
|
||||
|
||||
|
||||
def test_set_and_get_by_string_key():
|
||||
Configuration.set("my_key", "my_value")
|
||||
assert Configuration.get("my_key") == "my_value"
|
||||
|
||||
|
||||
def test_get_missing_returns_default():
|
||||
assert Configuration.get("nonexistent", "fallback") == "fallback"
|
||||
|
||||
|
||||
def test_get_missing_returns_none():
|
||||
assert Configuration.get("nonexistent") is None
|
||||
|
||||
|
||||
def test_get_model_auto_instantiates():
|
||||
# Getting an unregistered ConfigurationModelABC subclass should auto-create it
|
||||
result = Configuration.get(ServerSettings)
|
||||
assert isinstance(result, ServerSettings)
|
||||
assert result.host == "localhost"
|
||||
assert result.port == 8080
|
||||
|
||||
|
||||
def test_overwrite_existing():
|
||||
Configuration.set("key", "first")
|
||||
Configuration.set("key", "second")
|
||||
assert Configuration.get("key") == "second"
|
||||
|
||||
|
||||
def test_multiple_models():
|
||||
Configuration.set(AppSettings, AppSettings({"app_name": "App"}))
|
||||
Configuration.set(ServerSettings, ServerSettings({"port": 9000}))
|
||||
assert Configuration.get(AppSettings).app_name == "App"
|
||||
assert Configuration.get(ServerSettings).port == 9000
|
||||
|
||||
|
||||
# --- add_json_file ---
|
||||
|
||||
def test_add_json_file_loads_model(tmp_path):
|
||||
config_data = {"AppSettings": {"app_name": "FromFile", "version": "2.0.0"}}
|
||||
config_file = tmp_path / "appsettings.json"
|
||||
config_file.write_text(json.dumps(config_data), encoding="utf-8")
|
||||
|
||||
Configuration.add_json_file(str(config_file), output=False)
|
||||
|
||||
result = Configuration.get(AppSettings)
|
||||
assert result.app_name == "FromFile"
|
||||
assert result.version == "2.0.0"
|
||||
|
||||
|
||||
def test_add_json_file_not_found_exits(tmp_path):
|
||||
with pytest.raises(SystemExit):
|
||||
Configuration.add_json_file(str(tmp_path / "missing.json"), output=False)
|
||||
|
||||
|
||||
def test_add_json_file_optional_missing_returns_none(tmp_path):
|
||||
result = Configuration.add_json_file(
|
||||
str(tmp_path / "missing.json"), optional=True, output=False
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_add_json_file_invalid_json(tmp_path):
|
||||
# _load_json_file catches parse errors and returns {} — no SystemExit.
|
||||
# add_json_file then proceeds with an empty config (no models loaded).
|
||||
bad_file = tmp_path / "bad.json"
|
||||
bad_file.write_text("not valid json", encoding="utf-8")
|
||||
Configuration.add_json_file(str(bad_file), output=False)
|
||||
# No model should be registered since the file was invalid
|
||||
assert Configuration.get("AppSettings") is None
|
||||
Reference in New Issue
Block a user