- ProjectConfig dataclass for type-safe project settings - Channel-to-project mapping with required fields - Environment variable expansion ($HOME, custom vars) - System prompt loading from file or inline - Tool restriction per project - Model selection per project - Config class with comprehensive YAML loading - Default config: ~/.claude-coordinator/config.yaml - Full validation with detailed error messages - Duplicate channel ID detection - Project directory existence checks - Tool name validation (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch) - Model validation (sonnet, opus, haiku) - System prompt file validation - Example config with all options documented - Full example with all fields - Minimal example with defaults - Environment variable usage - Read-only project example - Inline comments explaining all options - Comprehensive test suite: 25/25 tests passing - ProjectConfig creation and defaults - Environment variable expansion - System prompt loading (inline and file) - YAML loading and parsing - Required field validation - Duplicate channel ID detection - Invalid tool name detection - Missing directory warnings - Invalid model detection - Channel ID lookup - Numeric channel ID conversion Progress: 6/6 Week 1 tasks complete Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
487 lines
15 KiB
Python
487 lines
15 KiB
Python
"""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
|