diff --git a/tests/unit/config_handling/test_settings_loader.py b/tests/unit/config_handling/test_settings_loader.py new file mode 100644 index 0000000..f4dd966 --- /dev/null +++ b/tests/unit/config_handling/test_settings_loader.py @@ -0,0 +1,708 @@ +""" +Unit tests for SettingsLoader class +""" + +import configparser +from pathlib import Path +from unittest.mock import Mock +import pytest +from pytest import CaptureFixture +from corelibs.config_handling.settings_loader import SettingsLoader +from corelibs.logging_handling.log import Log + + +class TestSettingsLoaderInit: + """Test cases for SettingsLoader initialization""" + + def test_init_with_valid_config_file(self, tmp_path: Path): + """Test initialization with a valid config file""" + config_file = tmp_path / "test.ini" + config_file.write_text("[Section]\nkey=value\n") + + loader = SettingsLoader( + args={}, + config_file=config_file, + log=None, + always_print=False + ) + + assert loader.args == {} + assert loader.config_file == config_file + assert loader.log is None + assert loader.always_print is False + assert loader.config_parser is not None + assert isinstance(loader.config_parser, configparser.ConfigParser) + + def test_init_with_missing_config_file(self, tmp_path: Path): + """Test initialization with missing config file""" + config_file = tmp_path / "missing.ini" + + loader = SettingsLoader( + args={}, + config_file=config_file, + log=None, + always_print=False + ) + + assert loader.config_parser is None + + def test_init_with_invalid_config_folder(self): + """Test initialization with invalid config folder path""" + config_file = Path("/nonexistent/path/test.ini") + + with pytest.raises(ValueError, match="Cannot find the config folder"): + SettingsLoader( + args={}, + config_file=config_file, + log=None, + always_print=False + ) + + def test_init_with_log(self, tmp_path: Path): + """Test initialization with Log object""" + config_file = tmp_path / "test.ini" + config_file.write_text("[Section]\nkey=value\n") + mock_log = Mock(spec=Log) + + loader = SettingsLoader( + args={"test": "value"}, + config_file=config_file, + log=mock_log, + always_print=True + ) + + assert loader.log == mock_log + assert loader.always_print is True + + +class TestLoadSettings: + """Test cases for load_settings method""" + + def test_load_settings_basic(self, tmp_path: Path): + """Test loading basic settings without validation""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nkey1=value1\nkey2=value2\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings("TestSection") + + assert result == {"key1": "value1", "key2": "value2"} + + def test_load_settings_with_missing_section(self, tmp_path: Path): + """Test loading settings with missing section""" + config_file = tmp_path / "test.ini" + config_file.write_text("[OtherSection]\nkey=value\n") + + loader = SettingsLoader(args={}, config_file=config_file) + + with pytest.raises(ValueError, match="Cannot read \\[MissingSection\\]"): + loader.load_settings("MissingSection") + + def test_load_settings_allow_not_exist(self, tmp_path: Path): + """Test loading settings with allow_not_exist flag""" + config_file = tmp_path / "test.ini" + config_file.write_text("[OtherSection]\nkey=value\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings("MissingSection", allow_not_exist=True) + + assert result == {} + + def test_load_settings_mandatory_field_present(self, tmp_path: Path): + """Test mandatory field validation when field is present""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nrequired_field=value\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"required_field": ["mandatory:yes"]} + ) + + assert result["required_field"] == "value" + + def test_load_settings_mandatory_field_missing(self, tmp_path: Path): + """Test mandatory field validation when field is missing""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nother_field=value\n") + + loader = SettingsLoader(args={}, config_file=config_file) + + with pytest.raises(ValueError, match="Missing or incorrect settings data"): + loader.load_settings( + "TestSection", + {"required_field": ["mandatory:yes"]} + ) + + def test_load_settings_mandatory_field_empty(self, tmp_path: Path): + """Test mandatory field validation when field is empty""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nrequired_field=\n") + + loader = SettingsLoader(args={}, config_file=config_file) + + with pytest.raises(ValueError, match="Missing or incorrect settings data"): + loader.load_settings( + "TestSection", + {"required_field": ["mandatory:yes"]} + ) + + def test_load_settings_with_split(self, tmp_path: Path): + """Test splitting values into lists""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nlist_field=a,b,c,d\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"list_field": ["split:,"]} + ) + + assert result["list_field"] == ["a", "b", "c", "d"] + + def test_load_settings_with_custom_split_char(self, tmp_path: Path): + """Test splitting with custom delimiter""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nlist_field=a|b|c|d\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"list_field": ["split:|"]} + ) + + assert result["list_field"] == ["a", "b", "c", "d"] + + def test_load_settings_split_removes_spaces(self, tmp_path: Path): + """Test that split removes spaces from values""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nlist_field=a, b , c , d\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"list_field": ["split:,"]} + ) + + assert result["list_field"] == ["a", "b", "c", "d"] + + def test_load_settings_empty_split_char_fallback(self, tmp_path: Path, capsys: CaptureFixture[str]): + """Test fallback to default split char when empty""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nlist_field=a,b,c\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"list_field": ["split:"]} + ) + + assert result["list_field"] == ["a", "b", "c"] + captured = capsys.readouterr() + assert "fallback to:" in captured.out + + def test_load_settings_convert_to_int(self, tmp_path: Path): + """Test converting values to int""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nnumber=123\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"number": ["convert:int"]} + ) + + assert result["number"] == 123 + assert isinstance(result["number"], int) + + def test_load_settings_convert_to_float(self, tmp_path: Path): + """Test converting values to float""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nnumber=123.45\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"number": ["convert:float"]} + ) + + assert result["number"] == 123.45 + assert isinstance(result["number"], float) + + def test_load_settings_convert_to_bool_true(self, tmp_path: Path): + """Test converting values to boolean True""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nflag1=true\nflag2=True\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"flag1": ["convert:bool"], "flag2": ["convert:bool"]} + ) + + assert result["flag1"] is True + assert result["flag2"] is True + + def test_load_settings_convert_to_bool_false(self, tmp_path: Path): + """Test converting values to boolean False""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nflag1=false\nflag2=False\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"flag1": ["convert:bool"], "flag2": ["convert:bool"]} + ) + + assert result["flag1"] is False + assert result["flag2"] is False + + def test_load_settings_convert_invalid_type(self, tmp_path: Path): + """Test converting with invalid type raises error""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nvalue=test\n") + + loader = SettingsLoader(args={}, config_file=config_file) + + with pytest.raises(ValueError, match="convert type is invalid"): + loader.load_settings( + "TestSection", + {"value": ["convert:invalid"]} + ) + + def test_load_settings_empty_set_to_none(self, tmp_path: Path): + """Test setting empty values to None""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nother=value\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"field": ["empty:"]} + ) + + assert result["field"] is None + + def test_load_settings_empty_set_to_custom_value(self, tmp_path: Path): + """Test setting empty values to custom value""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nother=value\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"field": ["empty:default"]} + ) + + assert result["field"] == "default" + + def test_load_settings_matching_valid(self, tmp_path: Path): + """Test matching validation with valid value""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nmode=production\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"mode": ["matching:development|staging|production"]} + ) + + assert result["mode"] == "production" + + def test_load_settings_matching_invalid(self, tmp_path: Path): + """Test matching validation with invalid value""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nmode=invalid\n") + + loader = SettingsLoader(args={}, config_file=config_file) + + with pytest.raises(ValueError, match="Missing or incorrect settings data"): + loader.load_settings( + "TestSection", + {"mode": ["matching:development|staging|production"]} + ) + + def test_load_settings_in_valid(self, tmp_path: Path): + """Test 'in' validation with valid value""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nallowed=a,b,c\nvalue=b\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + { + "allowed": ["split:,"], + "value": ["in:allowed"] + } + ) + + assert result["value"] == "b" + + def test_load_settings_in_invalid(self, tmp_path: Path): + """Test 'in' validation with invalid value""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nallowed=a,b,c\nvalue=d\n") + + loader = SettingsLoader(args={}, config_file=config_file) + + with pytest.raises(ValueError, match="Missing or incorrect settings data"): + loader.load_settings( + "TestSection", + { + "allowed": ["split:,"], + "value": ["in:allowed"] + } + ) + + def test_load_settings_in_missing_target(self, tmp_path: Path): + """Test 'in' validation with missing target""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nvalue=a\n") + + loader = SettingsLoader(args={}, config_file=config_file) + + with pytest.raises(ValueError, match="Missing or incorrect settings data"): + loader.load_settings( + "TestSection", + {"value": ["in:missing_target"]} + ) + + def test_load_settings_length_exact(self, tmp_path: Path): + """Test length validation with exact match""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nvalue=test\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"value": ["length:4"]} + ) + + assert result["value"] == "test" + + def test_load_settings_length_exact_invalid(self, tmp_path: Path): + """Test length validation with exact match failure""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nvalue=test\n") + + loader = SettingsLoader(args={}, config_file=config_file) + + with pytest.raises(ValueError, match="Missing or incorrect settings data"): + loader.load_settings( + "TestSection", + {"value": ["length:5"]} + ) + + def test_load_settings_length_range(self, tmp_path: Path): + """Test length validation with range""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nvalue=testing\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"value": ["length:5-10"]} + ) + + assert result["value"] == "testing" + + def test_load_settings_length_min_only(self, tmp_path: Path): + """Test length validation with minimum only""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nvalue=testing\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"value": ["length:5-"]} + ) + + assert result["value"] == "testing" + + def test_load_settings_length_max_only(self, tmp_path: Path): + """Test length validation with maximum only""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nvalue=test\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"value": ["length:-10"]} + ) + + assert result["value"] == "test" + + def test_load_settings_range_valid(self, tmp_path: Path): + """Test range validation with valid value""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nnumber=25\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"number": ["range:10-50"]} + ) + + assert result["number"] == "25" + + def test_load_settings_range_invalid(self, tmp_path: Path): + """Test range validation with invalid value""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nnumber=100\n") + + loader = SettingsLoader(args={}, config_file=config_file) + + with pytest.raises(ValueError, match="Missing or incorrect settings data"): + loader.load_settings( + "TestSection", + {"number": ["range:10-50"]} + ) + + def test_load_settings_check_int_valid(self, tmp_path: Path): + """Test check:int with valid integer""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nnumber=12345\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"number": ["check:int"]} + ) + + assert result["number"] == "12345" + + def test_load_settings_check_int_cleanup(self, tmp_path: Path): + """Test check:int with cleanup""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nnumber=12a34b5\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"number": ["check:int"]} + ) + + assert result["number"] == "12345" + + def test_load_settings_check_email_valid(self, tmp_path: Path): + """Test check:string.email.basic with valid email""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nemail=test@example.com\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"email": ["check:string.email.basic"]} + ) + + assert result["email"] == "test@example.com" + + def test_load_settings_check_email_invalid(self, tmp_path: Path): + """Test check:string.email.basic with invalid email""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nemail=not-an-email\n") + + loader = SettingsLoader(args={}, config_file=config_file) + + with pytest.raises(ValueError, match="Missing or incorrect settings data"): + loader.load_settings( + "TestSection", + {"email": ["check:string.email.basic"]} + ) + + def test_load_settings_args_override(self, tmp_path: Path, capsys: CaptureFixture[str]): + """Test command line arguments override config values""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nvalue=config_value\n") + + loader = SettingsLoader( + args={"value": "arg_value"}, + config_file=config_file + ) + result = loader.load_settings( + "TestSection", + {"value": []} + ) + + assert result["value"] == "arg_value" + captured = capsys.readouterr() + assert "Command line option override" in captured.out + + def test_load_settings_no_config_file_with_args(self, tmp_path: Path): + """Test loading settings without config file but with mandatory args""" + config_file = tmp_path / "missing.ini" + + loader = SettingsLoader( + args={"required": "value"}, + config_file=config_file + ) + result = loader.load_settings( + "TestSection", + {"required": ["mandatory:yes"]} + ) + + assert result["required"] == "value" + + def test_load_settings_no_config_file_missing_args(self, tmp_path: Path): + """Test loading settings without config file and missing args""" + config_file = tmp_path / "missing.ini" + + loader = SettingsLoader(args={}, config_file=config_file) + + with pytest.raises(ValueError, match="Cannot find file"): + loader.load_settings( + "TestSection", + {"required": ["mandatory:yes"]} + ) + + def test_load_settings_check_list_with_split(self, tmp_path: Path): + """Test check validation with list values""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nlist=abc,def,ghi\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"list": ["split:,", "check:string.alphanumeric"]} + ) + + assert result["list"] == ["abc", "def", "ghi"] + + def test_load_settings_check_list_cleanup(self, tmp_path: Path): + """Test check validation cleans up list values""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nlist=ab-c,de_f,gh!i\n") + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "TestSection", + {"list": ["split:,", "check:string.alphanumeric"]} + ) + + assert result["list"] == ["abc", "def", "ghi"] + + def test_load_settings_invalid_check_type(self, tmp_path: Path): + """Test with invalid check type""" + config_file = tmp_path / "test.ini" + config_file.write_text("[TestSection]\nvalue=test\n") + + loader = SettingsLoader(args={}, config_file=config_file) + + with pytest.raises(ValueError, match="Cannot get SettingsLoaderCheck.CHECK_SETTINGS"): + loader.load_settings( + "TestSection", + {"value": ["check:invalid.check.type"]} + ) + + +class TestComplexScenarios: + """Test cases for complex real-world scenarios""" + + def test_complex_validation_scenario(self, tmp_path: Path): + """Test complex scenario with multiple validations""" + config_file = tmp_path / "test.ini" + config_file.write_text( + "[Production]\n" + "environment=production\n" + "allowed_envs=development,staging,production\n" + "port=8080\n" + "host=example.com\n" + "timeout=30\n" + "debug=false\n" + "features=auth,logging,monitoring\n" + ) + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "Production", + { + "environment": [ + "mandatory:yes", + "matching:development|staging|production", + "in:allowed_envs" + ], + "allowed_envs": ["split:,"], + "port": ["mandatory:yes", "convert:int", "range:1-65535"], + "host": ["mandatory:yes"], + "timeout": ["convert:int", "range:1-"], + "debug": ["convert:bool"], + "features": ["split:,", "check:string.alphanumeric"], + } + ) + + assert result["environment"] == "production" + assert result["allowed_envs"] == ["development", "staging", "production"] + assert result["port"] == 8080 + assert isinstance(result["port"], int) + assert result["host"] == "example.com" + assert result["timeout"] == 30 + assert result["debug"] is False + assert result["features"] == ["auth", "logging", "monitoring"] + + def test_email_list_validation(self, tmp_path: Path): + """Test email list with validation""" + config_file = tmp_path / "test.ini" + config_file.write_text( + "[EmailConfig]\n" + "emails=test@example.com,admin@domain.org,user+tag@site.co.uk\n" + ) + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "EmailConfig", + {"emails": ["split:,", "mandatory:yes", "check:string.email.basic"]} + ) + + assert len(result["emails"]) == 3 + assert "test@example.com" in result["emails"] + + def test_mixed_args_and_config(self, tmp_path: Path): + """Test mixing command line args and config file""" + config_file = tmp_path / "test.ini" + config_file.write_text( + "[Settings]\n" + "value1=config_value1\n" + "value2=config_value2\n" + ) + + loader = SettingsLoader( + args={"value1": "arg_value1"}, + config_file=config_file + ) + result = loader.load_settings( + "Settings", + {"value1": [], "value2": []} + ) + + assert result["value1"] == "arg_value1" # Overridden by arg + assert result["value2"] == "config_value2" # From config + + def test_multiple_check_types(self, tmp_path: Path): + """Test multiple different check types""" + config_file = tmp_path / "test.ini" + config_file.write_text( + "[Checks]\n" + "numbers=123,456,789\n" + "alphas=abc,def,ghi\n" + "emails=test@example.com\n" + "date=2025-01-15\n" + ) + + loader = SettingsLoader(args={}, config_file=config_file) + result = loader.load_settings( + "Checks", + { + "numbers": ["split:,", "check:int"], + "alphas": ["split:,", "check:string.alphanumeric"], + "emails": ["check:string.email.basic"], + "date": ["check:string.date"], + } + ) + + assert result["numbers"] == ["123", "456", "789"] + assert result["alphas"] == ["abc", "def", "ghi"] + assert result["emails"] == "test@example.com" + assert result["date"] == "2025-01-15" + + +# __END__