- infrasynth.configs: typed multi-tenant config store (registry, service, secrets, cache) + public config_changed/config_reset signals and API - emit the declared-but-dead signals (features flags/overrides, scheduler task_completed/task_failed, tenancy tenant_updated, audit model_changed) and per-model audit field exclusions - security: permission catalog (security_permission), Django-style model-derived AutoPermission, PermissionRegistry, RoleAssignment, global-or-tenant Grant/Revoke, catalog API - consolidate the permission surface: PermissionRegistry only (drop the settings dict), IsAuthenticatedAndPermitted aliases HybridPermission, require_permission replaced by required_permissions + require_all - packaging: add [build-system]; add Forgejo publish workflow (.forgejo)
135 lines
5.1 KiB
Python
135 lines
5.1 KiB
Python
"""``ConfigRegistry`` registration and coercion."""
|
|
|
|
import decimal
|
|
|
|
import pytest
|
|
|
|
from infrasynth.configs.registry import INVALID_CODE, ConfigRegistry, ConfigType
|
|
from infrasynth.shared.exceptions import ValidationAppError
|
|
|
|
|
|
def _positive(value):
|
|
if value <= 0:
|
|
raise ValueError("must be positive")
|
|
|
|
|
|
class TestRegistration:
|
|
@pytest.fixture(autouse=True)
|
|
def clean_registry(self, clean_config_registry):
|
|
yield
|
|
|
|
def test_register_and_get(self):
|
|
definition = ConfigRegistry.register("branding.color", type=ConfigType.STRING, default="#000")
|
|
assert ConfigRegistry.get("branding.color") is definition
|
|
assert ConfigRegistry.get("missing") is None
|
|
|
|
def test_all_returns_copy(self):
|
|
ConfigRegistry.register("a")
|
|
snapshot = ConfigRegistry.all()
|
|
snapshot["b"] = None
|
|
assert "b" not in ConfigRegistry.all()
|
|
|
|
def test_register_accepts_string_type(self):
|
|
definition = ConfigRegistry.register("n", type="int", default=1)
|
|
assert definition.type is ConfigType.INT
|
|
|
|
def test_label_defaults_to_key(self):
|
|
definition = ConfigRegistry.register("some.key")
|
|
assert definition.label == "some.key"
|
|
|
|
|
|
class TestCoerce:
|
|
@pytest.fixture(autouse=True)
|
|
def clean_registry(self, clean_config_registry):
|
|
yield
|
|
|
|
def _definition(self, config_type, **kwargs):
|
|
return ConfigRegistry.register("k", type=config_type, **kwargs)
|
|
|
|
def test_string(self):
|
|
definition = self._definition(ConfigType.STRING)
|
|
assert ConfigRegistry.coerce(definition, "hi") == "hi"
|
|
|
|
def test_string_rejects_int(self):
|
|
definition = self._definition(ConfigType.STRING)
|
|
with pytest.raises(ValidationAppError) as exc:
|
|
ConfigRegistry.coerce(definition, 3)
|
|
assert exc.value.code == INVALID_CODE
|
|
|
|
def test_int_accepts_numeric_string(self):
|
|
definition = self._definition(ConfigType.INT)
|
|
assert ConfigRegistry.coerce(definition, "5") == 5
|
|
|
|
def test_int_rejects_bool(self):
|
|
definition = self._definition(ConfigType.INT)
|
|
with pytest.raises(ValidationAppError):
|
|
ConfigRegistry.coerce(definition, True)
|
|
|
|
def test_int_rejects_fractional_float(self):
|
|
definition = self._definition(ConfigType.INT)
|
|
with pytest.raises(ValidationAppError):
|
|
ConfigRegistry.coerce(definition, 1.5)
|
|
|
|
def test_float(self):
|
|
definition = self._definition(ConfigType.FLOAT)
|
|
assert ConfigRegistry.coerce(definition, "1.5") == 1.5
|
|
|
|
def test_decimal(self):
|
|
definition = self._definition(ConfigType.DECIMAL)
|
|
assert ConfigRegistry.coerce(definition, "1.25") == decimal.Decimal("1.25")
|
|
|
|
def test_bool_accepts_real_bool(self):
|
|
definition = self._definition(ConfigType.BOOL)
|
|
assert ConfigRegistry.coerce(definition, False) is False
|
|
|
|
def test_bool_rejects_truthy_string(self):
|
|
definition = self._definition(ConfigType.BOOL)
|
|
with pytest.raises(ValidationAppError):
|
|
ConfigRegistry.coerce(definition, "false")
|
|
|
|
def test_json_passthrough(self):
|
|
definition = self._definition(ConfigType.JSON)
|
|
assert ConfigRegistry.coerce(definition, {"a": [1, 2]}) == {"a": [1, 2]}
|
|
|
|
def test_json_rejects_unserializable(self):
|
|
definition = self._definition(ConfigType.JSON)
|
|
with pytest.raises(ValidationAppError):
|
|
ConfigRegistry.coerce(definition, object())
|
|
|
|
def test_choice_accepts_member(self):
|
|
definition = self._definition(ConfigType.CHOICE, choices=("a", "b"))
|
|
assert ConfigRegistry.coerce(definition, "b") == "b"
|
|
|
|
def test_choice_rejects_non_member(self):
|
|
definition = self._definition(ConfigType.CHOICE, choices=("a", "b"))
|
|
with pytest.raises(ValidationAppError) as exc:
|
|
ConfigRegistry.coerce(definition, "c")
|
|
assert exc.value.code == INVALID_CODE
|
|
|
|
def test_duration_from_int(self):
|
|
definition = self._definition(ConfigType.DURATION)
|
|
assert ConfigRegistry.coerce(definition, 90) == 90
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[("30s", 30), ("5m", 300), ("2h", 7200), ("1d", 86400), ("120", 120)],
|
|
)
|
|
def test_duration_from_string(self, raw, expected):
|
|
definition = self._definition(ConfigType.DURATION)
|
|
assert ConfigRegistry.coerce(definition, raw) == expected
|
|
|
|
def test_duration_rejects_garbage(self):
|
|
definition = self._definition(ConfigType.DURATION)
|
|
with pytest.raises(ValidationAppError):
|
|
ConfigRegistry.coerce(definition, "soon")
|
|
|
|
def test_custom_validator_passes(self):
|
|
definition = self._definition(ConfigType.INT, validator="tests.test_configs.test_registry._positive")
|
|
assert ConfigRegistry.coerce(definition, 2) == 2
|
|
|
|
def test_custom_validator_failure_is_typed(self):
|
|
definition = self._definition(ConfigType.INT, validator="tests.test_configs.test_registry._positive")
|
|
with pytest.raises(ValidationAppError) as exc:
|
|
ConfigRegistry.coerce(definition, -1)
|
|
assert exc.value.code == INVALID_CODE
|
|
assert exc.value.details[0]["field"] == "k"
|