ai-assistant-discord-bot/tests/test_config.py
Claude Discord Bot b2ff6f19f2 Implement CRIT-006: Config system with YAML validation
- 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>
2026-02-13 18:01:49 +00:00

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