"""Tests for configuration layer.""" import os from pathlib import Path import pytest from ai_orchestrator.config import ( ServiceConfig, build_config, expand_path, resolve_env_var, validate_dispatch_config, ) class TestResolveEnvVar: """Tests for environment variable resolution.""" def test_resolves_env_var(self, monkeypatch): monkeypatch.setenv("MY_TOKEN", "secret123") assert resolve_env_var("$MY_TOKEN") == "secret123" def test_missing_env_var_returns_empty(self, monkeypatch): monkeypatch.delenv("NONEXISTENT_VAR", raising=False) assert resolve_env_var("$NONEXISTENT_VAR") == "" def test_literal_value_unchanged(self): assert resolve_env_var("literal_value") == "literal_value" def test_non_string_passthrough(self): assert resolve_env_var(123) == 123 # type: ignore class TestExpandPath: """Tests for path expansion.""" def test_tilde_expansion(self, tmp_path): result = expand_path("~/workspaces", tmp_path) assert str(Path.home()) in result def test_relative_path_resolved(self, tmp_path): result = expand_path("./workspaces", tmp_path) assert str(tmp_path) in result def test_absolute_path_unchanged(self, tmp_path): if os.name == "nt": result = expand_path("C:\\workspaces", tmp_path) assert "C:\\" in result else: result = expand_path("/tmp/workspaces", tmp_path) assert result.startswith("/tmp") def test_empty_uses_default(self, tmp_path): result = expand_path("", tmp_path) assert "symphony_workspaces" in result class TestBuildConfig: """Tests for building typed config from raw dict.""" def test_defaults_applied(self, tmp_path): config = build_config({}, tmp_path) assert config.polling.interval_ms == 30000 assert config.agent.max_concurrent_agents == 10 assert config.agent.max_turns == 20 assert config.codex.command == "codex app-server" assert config.codex.turn_timeout_ms == 3600000 assert config.hooks.timeout_ms == 60000 def test_tracker_config(self, tmp_path, monkeypatch): monkeypatch.setenv("LINEAR_API_KEY", "test-key") raw = { "tracker": { "kind": "linear", "project_slug": "my-proj", "active_states": ["Ready", "Working"], } } config = build_config(raw, tmp_path) assert config.tracker.kind == "linear" assert config.tracker.project_slug == "my-proj" assert config.tracker.api_key == "test-key" assert config.tracker.active_states == ["Ready", "Working"] assert config.tracker.endpoint == "https://api.linear.app/graphql" def test_per_state_concurrency_normalization(self, tmp_path): raw = { "agent": { "max_concurrent_agents_by_state": { "In Progress": 3, "Todo": "invalid", "Review": -1, } } } config = build_config(raw, tmp_path) # Only valid positive entries kept, normalized to lowercase assert config.agent.max_concurrent_agents_by_state == {"in progress": 3} class TestValidateDispatchConfig: """Tests for dispatch config validation.""" def test_valid_config(self, tmp_path, monkeypatch): monkeypatch.setenv("LINEAR_API_KEY", "test-key") raw = { "tracker": {"kind": "linear", "project_slug": "proj"}, } config = build_config(raw, tmp_path) errors = validate_dispatch_config(config) assert errors == [] def test_missing_tracker_kind(self, tmp_path): config = build_config({}, tmp_path) errors = validate_dispatch_config(config) assert any("tracker.kind" in e for e in errors) def test_unsupported_tracker_kind(self, tmp_path, monkeypatch): monkeypatch.setenv("LINEAR_API_KEY", "key") raw = {"tracker": {"kind": "jira", "project_slug": "x"}} config = build_config(raw, tmp_path) errors = validate_dispatch_config(config) assert any("Unsupported" in e for e in errors) def test_missing_api_key(self, tmp_path, monkeypatch): monkeypatch.delenv("LINEAR_API_KEY", raising=False) raw = {"tracker": {"kind": "linear", "project_slug": "x"}} config = build_config(raw, tmp_path) errors = validate_dispatch_config(config) assert any("api_key" in e for e in errors) def test_missing_project_slug(self, tmp_path, monkeypatch): monkeypatch.setenv("LINEAR_API_KEY", "key") raw = {"tracker": {"kind": "linear"}} config = build_config(raw, tmp_path) errors = validate_dispatch_config(config) assert any("project_slug" in e for e in errors)