Files
aitbc/tests/cli/test_config.py
2026-02-17 16:10:02 +01:00

554 lines
21 KiB
Python

"""Tests for config CLI commands"""
import pytest
import json
import yaml
import os
import tempfile
from pathlib import Path
from click.testing import CliRunner
from unittest.mock import Mock, patch
from aitbc_cli.commands.config import config
@pytest.fixture
def runner():
"""Create CLI runner"""
return CliRunner()
@pytest.fixture
def mock_config():
"""Mock configuration"""
config = Mock()
config.coordinator_url = "http://127.0.0.1:18000"
config.api_key = None
config.timeout = 30
config.config_file = "/home/oib/.aitbc/config.yaml"
return config
@pytest.fixture
def temp_config_file():
"""Create temporary config file"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
config_data = {
"coordinator_url": "http://test:8000",
"api_key": "test_key",
"timeout": 60
}
yaml.dump(config_data, f)
temp_path = f.name
yield temp_path
# Cleanup
os.unlink(temp_path)
class TestConfigCommands:
"""Test config command group"""
def test_show_config(self, runner, mock_config):
"""Test showing current configuration"""
result = runner.invoke(config, [
'show'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = json.loads(result.output)
assert data['coordinator_url'] == 'http://127.0.0.1:18000'
assert data['api_key'] is None # mock_config has api_key=None
assert data['timeout'] == 30
def test_set_coordinator_url(self, runner, mock_config, tmp_path):
"""Test setting coordinator URL"""
with runner.isolated_filesystem():
result = runner.invoke(config, [
'set',
'coordinator_url',
'http://new:8000'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'Coordinator URL set to: http://new:8000' in result.output
# Verify file was created in current directory
config_file = Path.cwd() / ".aitbc.yaml"
assert config_file.exists()
with open(config_file) as f:
saved_config = yaml.safe_load(f)
assert saved_config['coordinator_url'] == 'http://new:8000'
def test_set_api_key(self, runner, mock_config):
"""Test setting API key"""
result = runner.invoke(config, [
'set',
'api_key',
'new_test_key_12345'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'API key set (use --global to set permanently)' in result.output
def test_set_timeout(self, runner, mock_config):
"""Test setting timeout"""
with runner.isolated_filesystem():
result = runner.invoke(config, [
'set',
'timeout',
'45'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'Timeout set to: 45s' in result.output
def test_set_invalid_timeout(self, runner, mock_config):
"""Test setting invalid timeout"""
result = runner.invoke(config, [
'set',
'timeout',
'invalid'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'Timeout must be an integer' in result.output
def test_set_invalid_key(self, runner, mock_config):
"""Test setting invalid configuration key"""
result = runner.invoke(config, [
'set',
'invalid_key',
'value'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'Unknown configuration key' in result.output
def test_path_command(self, runner, mock_config, tmp_path):
"""Test showing configuration file path"""
with runner.isolated_filesystem():
result = runner.invoke(config, [
'path'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert '.aitbc.yaml' in result.output
def test_path_global(self, runner, mock_config):
"""Test showing global config path"""
result = runner.invoke(config, [
'path',
'--global'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert '.config/aitbc/config.yaml' in result.output
@patch('os.system')
def test_edit_command(self, mock_system, runner, mock_config, tmp_path):
"""Test editing configuration file"""
# Change to the tmp_path directory
with runner.isolated_filesystem(temp_dir=tmp_path):
# The actual config file will be in the current working directory
actual_config_file = Path.cwd() / ".aitbc.yaml"
result = runner.invoke(config, [
'edit'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
# Verify editor was called
mock_system.assert_called_once()
assert 'nano' in mock_system.call_args[0][0]
assert str(actual_config_file) in mock_system.call_args[0][0]
def test_reset_config_cancelled(self, runner, mock_config, temp_config_file):
"""Test config reset cancelled by user"""
# Change to the directory containing the config file
config_dir = Path(temp_config_file).parent
with runner.isolated_filesystem(temp_dir=config_dir):
# Copy the config file to the current directory
import shutil
local_config = Path.cwd() / ".aitbc.yaml"
shutil.copy2(temp_config_file, local_config)
result = runner.invoke(config, [
'reset'
], obj={'config': mock_config, 'output_format': 'json'}, input='n\n')
assert result.exit_code == 0
# File should still exist
assert local_config.exists()
def test_reset_config_confirmed(self, runner, mock_config, temp_config_file):
"""Test config reset confirmed"""
# Change to the directory containing the config file
config_dir = Path(temp_config_file).parent
with runner.isolated_filesystem(temp_dir=config_dir):
# Copy the config file to the current directory
import shutil
local_config = Path.cwd() / ".aitbc.yaml"
shutil.copy2(temp_config_file, local_config)
result = runner.invoke(config, [
'reset'
], obj={'config': mock_config, 'output_format': 'table'}, input='y\n')
assert result.exit_code == 0
assert 'Configuration reset' in result.output
# File should be deleted
assert not local_config.exists()
def test_reset_no_config(self, runner, mock_config):
"""Test reset when no config file exists"""
with runner.isolated_filesystem():
result = runner.invoke(config, [
'reset'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
assert 'No configuration file found' in result.output
def test_export_yaml(self, runner, mock_config, temp_config_file):
"""Test exporting configuration as YAML"""
# Change to the directory containing the config file
config_dir = Path(temp_config_file).parent
with runner.isolated_filesystem(temp_dir=config_dir):
# Copy the config file to the current directory
import shutil
local_config = Path.cwd() / ".aitbc.yaml"
shutil.copy2(temp_config_file, local_config)
result = runner.invoke(config, [
'export',
'--format', 'yaml'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
output_data = yaml.safe_load(result.output)
assert output_data['coordinator_url'] == 'http://test:8000'
assert output_data['api_key'] == '***REDACTED***'
def test_export_json(self, runner, mock_config, temp_config_file):
"""Test exporting configuration as JSON"""
# Change to the directory containing the config file
config_dir = Path(temp_config_file).parent
with runner.isolated_filesystem(temp_dir=config_dir):
# Copy the config file to the current directory
import shutil
local_config = Path.cwd() / ".aitbc.yaml"
shutil.copy2(temp_config_file, local_config)
result = runner.invoke(config, [
'export',
'--format', 'json'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
data = json.loads(result.output)
assert data['coordinator_url'] == 'http://test:8000'
assert data['api_key'] == '***REDACTED***'
def test_export_empty_yaml(self, runner, mock_config, tmp_path):
"""Test exporting an empty YAML config file"""
with runner.isolated_filesystem(temp_dir=tmp_path):
local_config = Path.cwd() / ".aitbc.yaml"
local_config.write_text("")
result = runner.invoke(config, [
'export',
'--format', 'json'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
data = json.loads(result.output)
assert data == {}
def test_export_no_config(self, runner, mock_config):
"""Test export when no config file exists"""
with runner.isolated_filesystem():
result = runner.invoke(config, [
'export'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'No configuration file found' in result.output
def test_import_config_yaml(self, runner, mock_config, tmp_path):
"""Test importing YAML configuration"""
# Create import file
import_file = tmp_path / "import.yaml"
import_data = {
"coordinator_url": "http://imported:8000",
"timeout": 90
}
import_file.write_text(yaml.dump(import_data))
with runner.isolated_filesystem(temp_dir=tmp_path):
# The config file will be created in the current directory
actual_config_file = Path.cwd() / ".aitbc.yaml"
result = runner.invoke(config, [
'import-config',
str(import_file)
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'Configuration imported' in result.output
# Verify import
with open(actual_config_file) as f:
saved_config = yaml.safe_load(f)
assert saved_config['coordinator_url'] == 'http://imported:8000'
assert saved_config['timeout'] == 90
def test_import_config_json(self, runner, mock_config, tmp_path):
"""Test importing JSON configuration"""
# Create import file
import_file = tmp_path / "import.json"
import_data = {
"coordinator_url": "http://json:8000",
"timeout": 60
}
import_file.write_text(json.dumps(import_data))
config_file = tmp_path / ".aitbc.yaml"
with runner.isolated_filesystem(temp_dir=tmp_path):
# The config file will be created in the current directory
actual_config_file = Path.cwd() / ".aitbc.yaml"
result = runner.invoke(config, [
'import-config',
str(import_file)
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
# Verify import
with open(actual_config_file) as f:
saved_config = yaml.safe_load(f)
assert saved_config['coordinator_url'] == 'http://json:8000'
assert saved_config['timeout'] == 60
def test_import_merge(self, runner, mock_config, temp_config_file, tmp_path):
"""Test importing with merge option"""
# Create import file
import_file = tmp_path / "import.yaml"
import_data = {
"timeout": 45
}
import_file.write_text(yaml.dump(import_data))
# Change to the directory containing the config file
config_dir = Path(temp_config_file).parent
with runner.isolated_filesystem(temp_dir=config_dir):
# Copy the config file to the current directory
import shutil
local_config = Path.cwd() / ".aitbc.yaml"
shutil.copy2(temp_config_file, local_config)
result = runner.invoke(config, [
'import-config',
str(import_file),
'--merge'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
# Verify merge - original values should remain
with open(local_config) as f:
saved_config = yaml.safe_load(f)
assert saved_config['coordinator_url'] == 'http://test:8000' # Original
assert saved_config['timeout'] == 45 # Updated
def test_import_nonexistent_file(self, runner, mock_config):
"""Test importing non-existent file"""
result = runner.invoke(config, [
'import-config',
'/nonexistent/file.yaml'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'File not found' in result.output
def test_validate_valid_config(self, runner, mock_config):
"""Test validating valid configuration"""
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'Configuration valid' in result.output
def test_validate_missing_url(self, runner, mock_config):
"""Test validating config with missing URL"""
mock_config.coordinator_url = None
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code != 0
assert 'validation failed' in result.output
def test_validate_invalid_url(self, runner, mock_config):
"""Test validating config with invalid URL"""
mock_config.coordinator_url = "invalid-url"
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code != 0
assert 'validation failed' in result.output
def test_validate_short_api_key(self, runner, mock_config):
"""Test validating config with short API key"""
mock_config.api_key = "short"
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code != 0
assert 'validation failed' in result.output
def test_validate_no_api_key(self, runner, mock_config):
"""Test validating config without API key (warning)"""
mock_config.api_key = None
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'valid with warnings' in result.output
@patch.dict(os.environ, {'CLIENT_API_KEY': 'env_key_123'})
def test_environments(self, runner, mock_config):
"""Test listing environment variables"""
result = runner.invoke(config, [
'environments'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'CLIENT_API_KEY' in result.output
def test_profiles_save(self, runner, mock_config, tmp_path):
"""Test saving a configuration profile"""
# Patch Path.home to return tmp_path
with patch('pathlib.Path.home') as mock_home:
mock_home.return_value = tmp_path
result = runner.invoke(config, [
'profiles',
'save',
'test_profile'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert "Profile 'test_profile' saved" in result.output
# Verify profile was created
profile_file = tmp_path / ".config" / "aitbc" / "profiles" / "test_profile.yaml"
assert profile_file.exists()
with open(profile_file) as f:
profile_data = yaml.safe_load(f)
assert profile_data['coordinator_url'] == 'http://127.0.0.1:18000'
def test_profiles_list(self, runner, mock_config, tmp_path):
"""Test listing configuration profiles"""
# Create test profiles
profiles_dir = tmp_path / ".config" / "aitbc" / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
profile1 = profiles_dir / "profile1.yaml"
profile1.write_text(yaml.dump({"coordinator_url": "http://test1:8000"}))
profile2 = profiles_dir / "profile2.yaml"
profile2.write_text(yaml.dump({"coordinator_url": "http://test2:8000"}))
# Patch Path.home to return tmp_path
with patch('pathlib.Path.home') as mock_home:
mock_home.return_value = tmp_path
result = runner.invoke(config, [
'profiles',
'list'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'profile1' in result.output
assert 'profile2' in result.output
def test_profiles_load(self, runner, mock_config, tmp_path):
"""Test loading a configuration profile"""
# Create test profile
profiles_dir = tmp_path / ".config" / "aitbc" / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
profile_file = profiles_dir / "load_me.yaml"
profile_file.write_text(yaml.dump({"coordinator_url": "http://127.0.0.1:18000"}))
# Patch Path.home to return tmp_path
with patch('pathlib.Path.home') as mock_home:
mock_home.return_value = tmp_path
result = runner.invoke(config, [
'profiles',
'load',
'load_me'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert "Profile 'load_me' loaded" in result.output
def test_profiles_delete(self, runner, mock_config, tmp_path):
"""Test deleting a configuration profile"""
# Create test profile
profiles_dir = tmp_path / ".config" / "aitbc" / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
profile_file = profiles_dir / "delete_me.yaml"
profile_file.write_text(yaml.dump({"coordinator_url": "http://test:8000"}))
# Patch Path.home to return tmp_path
with patch('pathlib.Path.home') as mock_home:
mock_home.return_value = tmp_path
result = runner.invoke(config, [
'profiles',
'delete',
'delete_me'
], obj={'config': mock_config, 'output_format': 'table'}, input='y\n')
assert result.exit_code == 0
assert "Profile 'delete_me' deleted" in result.output
assert not profile_file.exists()
def test_profiles_delete_cancelled(self, runner, mock_config, tmp_path):
"""Test profile deletion cancelled by user"""
# Create test profile
profiles_dir = tmp_path / ".config" / "aitbc" / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
profile_file = profiles_dir / "keep_me.yaml"
profile_file.write_text(yaml.dump({"coordinator_url": "http://test:8000"}))
# Patch Path.home to return tmp_path
with patch('pathlib.Path.home') as mock_home:
mock_home.return_value = tmp_path
result = runner.invoke(config, [
'profiles',
'delete',
'keep_me'
], obj={'config': mock_config, 'output_format': 'json'}, input='n\n')
assert result.exit_code == 0
assert profile_file.exists() # Should still exist