"""
Unit tests for the configuration module.
Tests cover configuration loading from environment variables, YAML files,
validation, and default values.
"""
import os
from importlib.metadata import version as _pkg_version
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
import tempfile
import pytest
import yaml
from pydantic import ValidationError
from openscad_mcp.utils.config import (
Config,
RenderingConfig,
CacheConfig,
SecurityConfig,
LoggingConfig,
ServerConfig,
get_config,
set_config,
)
from openscad_mcp.types import TransportType
class TestRenderingConfig:
"""Test the RenderingConfig model."""
@pytest.mark.unit
@pytest.mark.config
def test_rendering_config_defaults(self):
"""Test RenderingConfig creates with default values."""
config = RenderingConfig()
assert config.max_concurrent == 5
assert config.timeout_seconds == 300
assert config.max_image_width == 4096
assert config.max_image_height == 4096
assert config.default_color_scheme == "Cornfield"
@pytest.mark.unit
@pytest.mark.config
def test_rendering_config_validation_bounds(self):
"""Test RenderingConfig validates parameter bounds."""
# Test max_concurrent bounds
with pytest.raises(ValidationError):
RenderingConfig(max_concurrent=0)
with pytest.raises(ValidationError):
RenderingConfig(max_concurrent=21)
# Test timeout bounds
with pytest.raises(ValidationError):
RenderingConfig(timeout_seconds=29)
with pytest.raises(ValidationError):
RenderingConfig(timeout_seconds=3601)
@pytest.mark.unit
@pytest.mark.config
def test_rendering_config_custom_values(self):
"""Test RenderingConfig accepts custom valid values."""
config = RenderingConfig(
max_concurrent=10,
timeout_seconds=600,
max_image_width=2048,
max_image_height=2048,
default_color_scheme="Sunset"
)
assert config.max_concurrent == 10
assert config.timeout_seconds == 600
assert config.max_image_width == 2048
assert config.max_image_height == 2048
assert config.default_color_scheme == "Sunset"
class TestCacheConfig:
"""Test the CacheConfig model."""
@pytest.mark.unit
@pytest.mark.config
def test_cache_config_defaults(self):
"""Test CacheConfig creates with default values."""
config = CacheConfig()
assert config.enabled is True
assert config.max_size_mb == 500
assert config.ttl_hours == 24
assert str(config.directory).endswith('.cache/openscad-mcp')
@pytest.mark.unit
@pytest.mark.config
def test_cache_directory_not_created_at_validation(self):
"""Test that cache directory is NOT created during validation."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / "test_cache"
assert not cache_dir.exists()
config = CacheConfig(directory=cache_dir)
# Directory should NOT be created by the validator
assert not cache_dir.exists()
assert config.directory == cache_dir
@pytest.mark.unit
@pytest.mark.config
def test_ensure_cache_directory(self):
"""Test that ensure_cache_directory creates the directory explicitly."""
with tempfile.TemporaryDirectory() as tmpdir:
cache_dir = Path(tmpdir) / "test_cache"
assert not cache_dir.exists()
config = CacheConfig(directory=cache_dir)
config.ensure_cache_directory()
assert cache_dir.exists()
@pytest.mark.unit
@pytest.mark.config
def test_cache_config_validation(self):
"""Test CacheConfig validation bounds."""
# Test max_size_mb bounds
with pytest.raises(ValidationError):
CacheConfig(max_size_mb=99)
with pytest.raises(ValidationError):
CacheConfig(max_size_mb=10001)
# Test ttl_hours bounds
with pytest.raises(ValidationError):
CacheConfig(ttl_hours=0)
with pytest.raises(ValidationError):
CacheConfig(ttl_hours=169)
class TestSecurityConfig:
"""Test the SecurityConfig model."""
@pytest.mark.unit
@pytest.mark.config
def test_security_config_defaults(self):
"""Test SecurityConfig creates with default values."""
config = SecurityConfig()
assert config.rate_limit == 60
assert config.max_file_size_mb == 10
assert config.allowed_paths is None
@pytest.mark.unit
@pytest.mark.config
def test_security_config_validation(self):
"""Test SecurityConfig validation bounds."""
# Test rate_limit bounds
with pytest.raises(ValidationError):
SecurityConfig(rate_limit=-1)
with pytest.raises(ValidationError):
SecurityConfig(rate_limit=1001)
# Test max_file_size_mb bounds
with pytest.raises(ValidationError):
SecurityConfig(max_file_size_mb=0)
with pytest.raises(ValidationError):
SecurityConfig(max_file_size_mb=101)
@pytest.mark.unit
@pytest.mark.config
def test_security_config_allowed_paths(self):
"""Test SecurityConfig with allowed paths."""
paths = ["/home/user/scad", "/tmp/openscad"]
config = SecurityConfig(allowed_paths=paths)
assert config.allowed_paths == paths
assert len(config.allowed_paths) == 2
class TestLoggingConfig:
"""Test the LoggingConfig model."""
@pytest.mark.unit
@pytest.mark.config
def test_logging_config_defaults(self):
"""Test LoggingConfig creates with default values."""
config = LoggingConfig()
assert config.level == "INFO"
assert config.file is None
assert config.max_size_mb == 100
assert config.rotate_count == 5
@pytest.mark.unit
@pytest.mark.config
def test_logging_config_level_validation(self):
"""Test LoggingConfig validates log level."""
# Valid levels
for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
config = LoggingConfig(level=level)
assert config.level == level
# Invalid level
with pytest.raises(ValidationError):
LoggingConfig(level="INVALID")
@pytest.mark.unit
@pytest.mark.config
def test_logging_config_bounds(self):
"""Test LoggingConfig validation bounds."""
# Test max_size_mb bounds
with pytest.raises(ValidationError):
LoggingConfig(max_size_mb=9)
with pytest.raises(ValidationError):
LoggingConfig(max_size_mb=1001)
# Test rotate_count bounds
with pytest.raises(ValidationError):
LoggingConfig(rotate_count=0)
with pytest.raises(ValidationError):
LoggingConfig(rotate_count=11)
class TestServerConfig:
"""Test the ServerConfig model."""
@pytest.mark.unit
@pytest.mark.config
def test_server_config_defaults(self):
"""Test ServerConfig creates with default values."""
config = ServerConfig()
assert config.name == "OpenSCAD MCP Server"
assert config.version == _pkg_version("openscad-mcp")
assert config.transport == TransportType.STDIO
assert config.host == "localhost"
assert config.port == 8000
@pytest.mark.unit
@pytest.mark.config
def test_server_config_transport_types(self):
"""Test ServerConfig accepts all transport types."""
for transport in [TransportType.STDIO, TransportType.HTTP, TransportType.SSE]:
config = ServerConfig(transport=transport)
assert config.transport == transport
@pytest.mark.unit
@pytest.mark.config
def test_server_config_port_validation(self):
"""Test ServerConfig validates port range."""
# Valid ports
config = ServerConfig(port=1024)
assert config.port == 1024
config = ServerConfig(port=65535)
assert config.port == 65535
# Invalid ports
with pytest.raises(ValidationError):
ServerConfig(port=1023)
with pytest.raises(ValidationError):
ServerConfig(port=65536)
class TestConfig:
"""Test the main Config model."""
@pytest.mark.unit
@pytest.mark.config
def test_config_defaults(self):
"""Test Config creates with default values."""
config = Config()
assert config.openscad_path is None
assert config.imagemagick_path is None
assert config.temp_dir == Path("/tmp/openscad-mcp")
assert isinstance(config.server, ServerConfig)
assert isinstance(config.rendering, RenderingConfig)
assert isinstance(config.cache, CacheConfig)
assert isinstance(config.security, SecurityConfig)
assert isinstance(config.logging, LoggingConfig)
@pytest.mark.unit
@pytest.mark.config
def test_config_temp_dir_custom(self):
"""Test Config accepts a custom temp_dir."""
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir) / "test_temp"
config = Config(temp_dir=temp_path)
assert config.temp_dir == temp_path
@pytest.mark.unit
@pytest.mark.config
def test_config_from_env(self, mock_env_vars):
"""Test Config loads from environment variables."""
config = Config.from_env()
assert config.openscad_path == "/usr/bin/openscad"
assert str(config.temp_dir) == "/tmp/test-mcp"
assert config.rendering.max_concurrent == 10
assert config.rendering.timeout_seconds == 600
assert config.cache.enabled is True
assert config.logging.level == "DEBUG"
@pytest.mark.unit
@pytest.mark.config
def test_config_from_env_partial(self):
"""Test Config loads partial environment variables."""
env_vars = {
'OPENSCAD_PATH': '/custom/openscad',
'MCP_LOG_LEVEL': 'WARNING',
}
with patch.dict(os.environ, env_vars, clear=False):
config = Config.from_env()
assert config.openscad_path == "/custom/openscad"
assert config.logging.level == "WARNING"
# Check defaults are still used
assert config.rendering.max_concurrent == 5
assert config.cache.enabled is True
@pytest.mark.unit
@pytest.mark.config
def test_config_from_env_with_dotenv(self, temp_dir):
"""Test Config loads from .env file."""
env_content = """
OPENSCAD_PATH=/from/dotenv/openscad
MCP_MAX_CONCURRENT_RENDERS=15
MCP_CACHE_ENABLED=false
"""
env_file = temp_dir / ".env"
env_file.write_text(env_content)
config = Config.from_env(str(env_file))
assert config.openscad_path == "/from/dotenv/openscad"
assert config.rendering.max_concurrent == 15
assert config.cache.enabled is False
@pytest.mark.unit
@pytest.mark.config
def test_config_from_yaml(self, sample_yaml_config):
"""Test Config loads from YAML file."""
config = Config.from_yaml(str(sample_yaml_config))
assert config.server.name == "Test OpenSCAD Server"
assert config.server.version == "0.2.0"
assert config.server.transport == TransportType.STDIO
assert config.rendering.max_concurrent == 10
assert config.rendering.timeout_seconds == 600
assert config.rendering.default_color_scheme == "Sunset"
assert config.cache.enabled is True
assert config.cache.max_size_mb == 1000
assert config.cache.ttl_hours == 48
assert config.security.rate_limit == 100
assert config.security.max_file_size_mb == 20
@pytest.mark.unit
@pytest.mark.config
def test_config_to_yaml(self, temp_dir):
"""Test Config saves to YAML file."""
config = Config(
openscad_path="/test/openscad",
rendering=RenderingConfig(max_concurrent=8),
cache=CacheConfig(enabled=False)
)
yaml_file = temp_dir / "test_config.yaml"
config.to_yaml(str(yaml_file))
assert yaml_file.exists()
# Load and verify
with open(yaml_file) as f:
data = yaml.safe_load(f)
assert data['openscad_path'] == "/test/openscad"
assert data['rendering']['max_concurrent'] == 8
assert data['cache']['enabled'] is False
@pytest.mark.unit
@pytest.mark.config
def test_config_transport_type_from_env(self):
"""Test Config loads transport type from environment."""
for transport_str, transport_enum in [
("stdio", TransportType.STDIO),
("http", TransportType.HTTP),
("sse", TransportType.SSE),
]:
with patch.dict(os.environ, {'MCP_TRANSPORT': transport_str}):
config = Config.from_env()
assert config.server.transport == transport_enum
class TestConfigGlobalFunctions:
"""Test global configuration functions."""
@pytest.mark.unit
@pytest.mark.config
def test_get_config_singleton(self):
"""Test get_config returns singleton instance."""
config1 = get_config()
config2 = get_config()
assert config1 is config2
@pytest.mark.unit
@pytest.mark.config
def test_set_config(self):
"""Test set_config updates global configuration."""
custom_config = Config(openscad_path="/custom/path")
set_config(custom_config)
retrieved = get_config()
assert retrieved is custom_config
assert retrieved.openscad_path == "/custom/path"
@pytest.mark.unit
@pytest.mark.config
def test_reset_config(self, reset_config):
"""Test that reset_config fixture works correctly."""
# Set a custom config
custom_config = Config(openscad_path="/first")
set_config(custom_config)
assert get_config().openscad_path == "/first"
# After fixture cleanup (automatic), config should be reset
# This is tested implicitly by other tests running independently
class TestConfigEdgeCases:
"""Test edge cases and error conditions."""
@pytest.mark.unit
@pytest.mark.config
def test_invalid_yaml_file(self, temp_dir):
"""Test Config handles invalid YAML file."""
invalid_yaml = temp_dir / "invalid.yaml"
invalid_yaml.write_text("invalid: yaml: content: ][")
with pytest.raises(yaml.YAMLError):
Config.from_yaml(str(invalid_yaml))
@pytest.mark.unit
@pytest.mark.config
def test_missing_yaml_file(self):
"""Test Config handles missing YAML file."""
with pytest.raises(FileNotFoundError):
Config.from_yaml("/nonexistent/config.yaml")
@pytest.mark.unit
@pytest.mark.config
def test_env_var_type_conversion(self):
"""Test environment variable type conversions."""
env_vars = {
'MCP_MAX_CONCURRENT_RENDERS': 'not_a_number',
'MCP_CACHE_ENABLED': 'not_a_bool',
}
with patch.dict(os.environ, env_vars):
# Should raise ValueError for invalid integer
with pytest.raises(ValueError):
Config.from_env()
@pytest.mark.unit
@pytest.mark.config
def test_cache_enabled_string_conversion(self):
"""Test cache enabled string to boolean conversion."""
test_cases = [
("true", True),
("True", True),
("TRUE", True),
("false", False),
("False", False),
("FALSE", False),
("anything_else", False),
]
for str_val, expected_bool in test_cases:
with patch.dict(os.environ, {'MCP_CACHE_ENABLED': str_val}):
config = Config.from_env()
assert config.cache.enabled == expected_bool