"""Tests for configuration management. Tests YAML loading, validation, project lookups, and error handling for the channel-to-project configuration system. """ import os import pytest import tempfile import yaml from pathlib import Path from claude_coordinator.config import Config, ProjectConfig, VALID_TOOLS @pytest.fixture def temp_config_dir(tmp_path): """Create temporary directories for testing. Creates a test project directory structure for validating project_dir existence checks. """ projects = tmp_path / "projects" projects.mkdir() # Create test project directories (projects / "major-domo").mkdir() (projects / "paper-dynasty").mkdir() (projects / "test-project").mkdir() # Create test system prompt file prompt_file = projects / "major-domo" / "CLAUDE.md" prompt_file.write_text("You are helping with Major Domo.") return projects @pytest.fixture def valid_config_data(temp_config_dir): """Create valid configuration data for testing.""" return { "projects": { "major-domo": { "channel_id": "123456", "project_dir": str(temp_config_dir / "major-domo"), "allowed_tools": ["Bash", "Read", "Write"], "system_prompt_file": str(temp_config_dir / "major-domo" / "CLAUDE.md"), "model": "sonnet", }, "paper-dynasty": { "channel_id": "789012", "project_dir": str(temp_config_dir / "paper-dynasty"), "system_prompt": "You help with Paper Dynasty.", "model": "opus", }, } } @pytest.fixture def config_file(tmp_path, valid_config_data): """Create a temporary config file for testing. Returns path to a valid YAML config file with test data. """ config_path = tmp_path / "config.yaml" with open(config_path, "w") as f: yaml.dump(valid_config_data, f) return config_path class TestProjectConfig: """Test ProjectConfig dataclass functionality.""" def test_project_config_creation(self): """Test creating a ProjectConfig with all fields.""" config = ProjectConfig( name="test-project", channel_id="123456", project_dir="/opt/projects/test", allowed_tools=["Bash", "Read"], system_prompt="Test prompt", model="sonnet", ) assert config.name == "test-project" assert config.channel_id == "123456" assert config.project_dir == "/opt/projects/test" assert config.allowed_tools == ["Bash", "Read"] assert config.system_prompt == "Test prompt" assert config.model == "sonnet" def test_project_config_defaults(self): """Test ProjectConfig default values.""" config = ProjectConfig( name="test", channel_id="123", project_dir="/opt/test", ) assert config.allowed_tools == list(VALID_TOOLS) assert config.model == "sonnet" assert config.system_prompt is None assert config.system_prompt_file is None def test_environment_variable_expansion(self, temp_config_dir): """Test that environment variables are expanded in paths.""" os.environ["TEST_PROJECT_DIR"] = str(temp_config_dir) config = ProjectConfig( name="test", channel_id="123", project_dir="$TEST_PROJECT_DIR/major-domo", ) assert config.project_dir == str(temp_config_dir / "major-domo") def test_get_system_prompt_inline(self): """Test getting inline system prompt.""" config = ProjectConfig( name="test", channel_id="123", project_dir="/opt/test", system_prompt="Inline prompt", ) assert config.get_system_prompt() == "Inline prompt" def test_get_system_prompt_from_file(self, temp_config_dir): """Test loading system prompt from file.""" prompt_file = temp_config_dir / "major-domo" / "CLAUDE.md" config = ProjectConfig( name="test", channel_id="123", project_dir="/opt/test", system_prompt_file=prompt_file, ) assert config.get_system_prompt() == "You are helping with Major Domo." def test_get_system_prompt_file_not_found(self): """Test error when system prompt file doesn't exist.""" config = ProjectConfig( name="test", channel_id="123", project_dir="/opt/test", system_prompt_file=Path("/nonexistent/file.md"), ) with pytest.raises(FileNotFoundError): config.get_system_prompt() def test_get_system_prompt_none(self): """Test when no system prompt is configured.""" config = ProjectConfig( name="test", channel_id="123", project_dir="/opt/test", ) assert config.get_system_prompt() is None class TestConfig: """Test Config class functionality.""" def test_load_valid_config(self, config_file): """Test loading a valid configuration file.""" config = Config(str(config_file)) config.load() assert len(config.projects) == 2 assert "major-domo" in config.projects assert "paper-dynasty" in config.projects def test_load_missing_file(self, tmp_path): """Test error when config file doesn't exist.""" config = Config(str(tmp_path / "nonexistent.yaml")) with pytest.raises(FileNotFoundError) as exc: config.load() assert "Configuration file not found" in str(exc.value) def test_load_invalid_yaml(self, tmp_path): """Test error with invalid YAML syntax.""" config_path = tmp_path / "invalid.yaml" config_path.write_text("invalid: yaml: syntax: [") config = Config(str(config_path)) with pytest.raises(yaml.YAMLError): config.load() def test_load_not_dict(self, tmp_path): """Test error when config is not a dictionary.""" config_path = tmp_path / "list.yaml" config_path.write_text("- item1\n- item2") config = Config(str(config_path)) with pytest.raises(ValueError) as exc: config.load() assert "must contain a YAML dictionary" in str(exc.value) def test_load_missing_projects_section(self, tmp_path): """Test error when 'projects' section is missing.""" config_path = tmp_path / "no_projects.yaml" with open(config_path, "w") as f: yaml.dump({"other_section": {}}, f) config = Config(str(config_path)) with pytest.raises(ValueError) as exc: config.load() assert "must contain a 'projects' section" in str(exc.value) def test_missing_required_fields(self, tmp_path, temp_config_dir): """Test error when required fields are missing.""" # Missing channel_id config_data = { "projects": { "test": { "project_dir": str(temp_config_dir / "test-project"), } } } config_path = tmp_path / "missing_channel.yaml" with open(config_path, "w") as f: yaml.dump(config_data, f) config = Config(str(config_path)) with pytest.raises(ValueError) as exc: config.load() assert "'channel_id' is required" in str(exc.value) def test_get_project_by_channel(self, config_file): """Test looking up project by channel ID.""" config = Config(str(config_file)) config.load() project = config.get_project_by_channel("123456") assert project is not None assert project.name == "major-domo" assert project.channel_id == "123456" def test_get_project_by_channel_not_found(self, config_file): """Test lookup returns None for unmapped channel.""" config = Config(str(config_file)) config.load() project = config.get_project_by_channel("999999") assert project is None def test_get_all_projects(self, config_file): """Test getting all configured projects.""" config = Config(str(config_file)) config.load() projects = config.get_all_projects() assert len(projects) == 2 assert "major-domo" in projects assert "paper-dynasty" in projects assert isinstance(projects["major-domo"], ProjectConfig) def test_validate_duplicate_channel_ids(self, tmp_path, temp_config_dir): """Test validation detects duplicate channel IDs.""" config_data = { "projects": { "project1": { "channel_id": "123456", "project_dir": str(temp_config_dir / "major-domo"), }, "project2": { "channel_id": "123456", # Duplicate "project_dir": str(temp_config_dir / "paper-dynasty"), }, } } config_path = tmp_path / "duplicate.yaml" with open(config_path, "w") as f: yaml.dump(config_data, f) config = Config(str(config_path)) with pytest.raises(ValueError) as exc: config.load() assert "Duplicate channel_id" in str(exc.value) assert "123456" in str(exc.value) def test_validate_invalid_tool_names(self, tmp_path, temp_config_dir): """Test validation detects invalid tool names.""" config_data = { "projects": { "test": { "channel_id": "123456", "project_dir": str(temp_config_dir / "test-project"), "allowed_tools": ["Bash", "InvalidTool", "FakeTool"], } } } config_path = tmp_path / "invalid_tools.yaml" with open(config_path, "w") as f: yaml.dump(config_data, f) config = Config(str(config_path)) with pytest.raises(ValueError) as exc: config.load() assert "invalid tool names" in str(exc.value) assert "InvalidTool" in str(exc.value) or "FakeTool" in str(exc.value) def test_validate_missing_project_directory(self, tmp_path): """Test validation warns about missing project directories.""" config_data = { "projects": { "test": { "channel_id": "123456", "project_dir": "/nonexistent/directory", } } } config_path = tmp_path / "missing_dir.yaml" with open(config_path, "w") as f: yaml.dump(config_data, f) config = Config(str(config_path)) with pytest.raises(ValueError) as exc: config.load() assert "directory does not exist" in str(exc.value) def test_validate_invalid_model(self, tmp_path, temp_config_dir): """Test validation detects invalid model names.""" config_data = { "projects": { "test": { "channel_id": "123456", "project_dir": str(temp_config_dir / "test-project"), "model": "invalid-model", } } } config_path = tmp_path / "invalid_model.yaml" with open(config_path, "w") as f: yaml.dump(config_data, f) config = Config(str(config_path)) with pytest.raises(ValueError) as exc: config.load() assert "invalid model" in str(exc.value) assert "invalid-model" in str(exc.value) def test_validate_missing_system_prompt_file(self, tmp_path, temp_config_dir): """Test validation detects missing system prompt files.""" config_data = { "projects": { "test": { "channel_id": "123456", "project_dir": str(temp_config_dir / "test-project"), "system_prompt_file": "/nonexistent/prompt.md", } } } config_path = tmp_path / "missing_prompt.yaml" with open(config_path, "w") as f: yaml.dump(config_data, f) config = Config(str(config_path)) with pytest.raises(ValueError) as exc: config.load() assert "system_prompt_file does not exist" in str(exc.value) def test_default_config_path(self, monkeypatch, tmp_path): """Test default config path uses ~/.claude-coordinator/config.yaml.""" # Create default config location config_dir = tmp_path / ".claude-coordinator" config_dir.mkdir() default_config = config_dir / "config.yaml" # Mock expanduser to return our tmp_path def mock_expanduser(path): return str(tmp_path / path.lstrip("~/")) monkeypatch.setattr(os.path, "expanduser", mock_expanduser) config = Config() assert str(config.config_path).endswith(".claude-coordinator/config.yaml") def test_channel_id_as_number(self, tmp_path, temp_config_dir): """Test that numeric channel IDs are converted to strings.""" config_data = { "projects": { "test": { "channel_id": 123456, # Number instead of string "project_dir": str(temp_config_dir / "test-project"), } } } config_path = tmp_path / "numeric_id.yaml" with open(config_path, "w") as f: yaml.dump(config_data, f) config = Config(str(config_path)) config.load() project = config.get_project_by_channel("123456") assert project is not None assert project.channel_id == "123456" class TestEnvironmentVariableExpansion: """Test environment variable expansion in configuration.""" def test_expand_home_variable(self, tmp_path): """Test $HOME variable expansion.""" home_dir = str(tmp_path / "home") os.makedirs(home_dir, exist_ok=True) project_dir = Path(home_dir) / "projects" / "test" project_dir.mkdir(parents=True) os.environ["HOME"] = home_dir config_data = { "projects": { "test": { "channel_id": "123456", "project_dir": "$HOME/projects/test", } } } config_path = tmp_path / "env_test.yaml" with open(config_path, "w") as f: yaml.dump(config_data, f) config = Config(str(config_path)) config.load() project = config.projects["test"] assert project.project_dir == str(project_dir) def test_expand_custom_variable(self, tmp_path): """Test custom environment variable expansion.""" custom_path = str(tmp_path / "custom") os.makedirs(custom_path, exist_ok=True) os.environ["CUSTOM_PROJECT_ROOT"] = custom_path config_data = { "projects": { "test": { "channel_id": "123456", "project_dir": "${CUSTOM_PROJECT_ROOT}", } } } config_path = tmp_path / "custom_var.yaml" with open(config_path, "w") as f: yaml.dump(config_data, f) config = Config(str(config_path)) config.load() project = config.projects["test"] assert project.project_dir == custom_path