Files
journal_organizer/tests/unit/test_claude_api_configuration.py
T

710 lines
27 KiB
Python
Raw Normal View History

"""
Unit tests for Claude API configuration system
Tests configuration loading, validation, environment variable expansion, and migration
"""
import os
import pytest
import tempfile
import yaml
from pathlib import Path
from unittest.mock import Mock, patch, AsyncMock
from typing import Dict, Any
# Import the modules we're testing
from config_validation import (
ClaudeAPIConfig, ConfigurationValidator, SystemConfig,
ObsidianConfig, ObsidianRestAPIConfig, JournalConfig,
OutputConfig, AnalysisConfig, LoggingConfig
)
from configuration_loader import ConfigurationLoader, ConfigurationError, EnvironmentVariableError
from configuration_migrator import ConfigurationMigrator
from claude_api_client import ClaudeAPIClient
from error_handling import ClaudeConfigurationError, ClaudeAPIURLError, ClaudeModelValidationError
class TestClaudeAPIConfig:
"""Test ClaudeAPIConfig validation"""
def test_valid_claude_config(self):
"""Test creating valid Claude API configuration"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="https://api.anthropic.com",
model="claude-3-5-sonnet-20241022",
max_tokens=4096,
temperature=0.7
)
assert config.api_key.startswith("sk-ant-")
assert config.api_url == "https://api.anthropic.com"
assert config.model == "claude-3-5-sonnet-20241022"
assert config.max_tokens == 4096
assert config.temperature == 0.7
def test_default_values(self):
"""Test default values are applied correctly"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890"
)
assert config.api_url == "https://api.anthropic.com"
assert config.model == "claude-3-5-sonnet-20241022"
assert config.max_tokens == 4096
assert config.temperature == 0.7
def test_custom_api_url(self):
"""Test custom API URL validation"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="https://custom-claude-api.example.com"
)
assert config.api_url == "https://custom-claude-api.example.com"
def test_api_url_trailing_slash_removal(self):
"""Test that trailing slashes are removed from API URLs"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="https://api.anthropic.com/"
)
assert config.api_url == "https://api.anthropic.com"
def test_invalid_api_url_format(self):
"""Test validation of invalid API URL formats"""
with pytest.raises(ValueError, match="Invalid URL format"):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="not-a-url"
)
def test_invalid_api_url_protocol(self):
"""Test validation of invalid URL protocols"""
with pytest.raises(ValueError, match="URL must use http or https protocol"):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="ftp://api.anthropic.com"
)
def test_invalid_api_key_format(self):
"""Test validation of invalid API key formats"""
with pytest.raises(ValueError, match="Claude API key should start with"):
ClaudeAPIConfig(api_key="invalid-key")
def test_api_key_too_short(self):
"""Test validation of API keys that are too short"""
with pytest.raises(ValueError, match="Claude API key appears to be too short"):
ClaudeAPIConfig(api_key="sk-ant-short")
def test_valid_model_names(self):
"""Test validation of valid model names"""
valid_models = [
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-latest",
"claude-3-sonnet-latest",
"claude-3-haiku-latest",
"claude-3-5-sonnet-latest",
"claude-3-5-haiku-latest"
]
for model in valid_models:
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
model=model
)
assert config.model == model
def test_invalid_model_name(self):
"""Test validation of invalid model names"""
with pytest.raises(ValueError, match="Invalid model name"):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
model="invalid-model"
)
def test_invalid_max_tokens(self):
"""Test validation of invalid max_tokens values"""
with pytest.raises(ValueError):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
max_tokens=0
)
with pytest.raises(ValueError):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
max_tokens=300000 # Too high
)
def test_invalid_temperature(self):
"""Test validation of invalid temperature values"""
with pytest.raises(ValueError):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
temperature=-0.1
)
with pytest.raises(ValueError):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
temperature=1.1
)
class TestConfigurationLoader:
"""Test ConfigurationLoader environment variable expansion"""
def setup_method(self):
"""Set up test fixtures"""
self.loader = ConfigurationLoader()
self.temp_dir = Path(tempfile.mkdtemp())
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
def test_simple_env_var_expansion(self):
"""Test simple environment variable expansion"""
os.environ['TEST_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
config_data = {
'claude': {
'api_key': '${TEST_API_KEY}'
}
}
expanded = self.loader.expand_environment_variables(config_data)
assert expanded['claude']['api_key'] == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
# Clean up
del os.environ['TEST_API_KEY']
def test_env_var_with_default(self):
"""Test environment variable expansion with default values"""
config_data = {
'claude': {
'api_url': '${CLAUDE_API_URL:-https://api.anthropic.com}',
'model': '${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}'
}
}
expanded = self.loader.expand_environment_variables(config_data)
assert expanded['claude']['api_url'] == 'https://api.anthropic.com'
assert expanded['claude']['model'] == 'claude-3-5-sonnet-20241022'
def test_env_var_override_default(self):
"""Test environment variable overriding default values"""
os.environ['CLAUDE_API_URL'] = 'https://custom-api.example.com'
config_data = {
'claude': {
'api_url': '${CLAUDE_API_URL:-https://api.anthropic.com}'
}
}
expanded = self.loader.expand_environment_variables(config_data)
assert expanded['claude']['api_url'] == 'https://custom-api.example.com'
# Clean up
del os.environ['CLAUDE_API_URL']
def test_missing_required_env_var(self):
"""Test error handling for missing required environment variables"""
config_data = {
'claude': {
'api_key': '${MISSING_API_KEY}'
}
}
with pytest.raises(EnvironmentVariableError, match="Environment variable 'MISSING_API_KEY' is not set"):
self.loader.expand_environment_variables(config_data)
def test_nested_env_var_expansion(self):
"""Test environment variable expansion in nested structures"""
os.environ['VAULT_PATH'] = '/test/vault'
os.environ['API_KEY'] = 'test-key'
config_data = {
'obsidian': {
'vault_path': '${VAULT_PATH}',
'rest_api': {
'api_key': '${API_KEY}'
}
}
}
expanded = self.loader.expand_environment_variables(config_data)
assert expanded['obsidian']['vault_path'] == '/test/vault'
assert expanded['obsidian']['rest_api']['api_key'] == 'test-key'
# Clean up
del os.environ['VAULT_PATH']
del os.environ['API_KEY']
def test_load_yaml_config(self):
"""Test loading YAML configuration file"""
config_data = {
'claude': {
'api_key': '${TEST_API_KEY:-default-key}',
'api_url': 'https://api.anthropic.com',
'model': 'claude-3-5-sonnet-20241022'
}
}
config_file = self.temp_dir / 'test_config.yaml'
with config_file.open('w') as f:
yaml.dump(config_data, f)
loaded_config = self.loader.load_config(config_file)
assert loaded_config['claude']['api_key'] == 'default-key'
assert loaded_config['claude']['api_url'] == 'https://api.anthropic.com'
def test_load_nonexistent_config(self):
"""Test error handling for nonexistent configuration files"""
nonexistent_file = self.temp_dir / 'nonexistent.yaml'
with pytest.raises(ConfigurationError, match="Configuration file not found"):
self.loader.load_config(nonexistent_file)
def test_validate_environment_variables(self):
"""Test validation of environment variables in configuration"""
config_data = {
'claude': {
'api_key': '${EXISTING_VAR}',
'api_url': '${MISSING_VAR}',
'model': '${VAR_WITH_DEFAULT:-default-model}'
}
}
os.environ['EXISTING_VAR'] = 'test-value'
missing_vars = self.loader.validate_environment_variables(config_data)
assert len(missing_vars) == 1
assert 'MISSING_VAR' in missing_vars[0]
# Clean up
del os.environ['EXISTING_VAR']
def test_get_environment_variable_references(self):
"""Test getting all environment variable references"""
config_data = {
'claude': {
'api_key': '${API_KEY}',
'api_url': '${API_URL:-default}',
'model': 'claude-3-5-sonnet-20241022'
},
'obsidian': {
'vault_path': '${VAULT_PATH}'
}
}
env_vars = self.loader.get_environment_variable_references(config_data)
assert 'API_KEY' in env_vars
assert 'API_URL' in env_vars
assert 'VAULT_PATH' in env_vars
assert 'claude.api_key' in env_vars['API_KEY']
assert 'claude.api_url' in env_vars['API_URL']
class TestConfigurationMigrator:
"""Test ConfigurationMigrator backward compatibility"""
def setup_method(self):
"""Set up test fixtures"""
self.migrator = ConfigurationMigrator()
def test_migrate_claude_config_missing_api_url(self):
"""Test migration of Claude config missing api_url"""
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-5-sonnet-20241022'
}
}
migrated = self.migrator.migrate_claude_config(config_dict)
assert migrated['claude']['api_url'] == 'https://api.anthropic.com'
assert migrated['claude']['api_key'] == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert migrated['claude']['model'] == 'claude-3-5-sonnet-20241022'
def test_migrate_legacy_model_names(self):
"""Test migration of legacy model names"""
legacy_models = {
'claude-3-sonnet': 'claude-3-sonnet-20240229',
'claude-3-opus': 'claude-3-opus-20240229',
'claude-3-haiku': 'claude-3-haiku-20240307',
'sonnet': 'claude-3-5-sonnet-20241022'
}
for old_model, expected_new_model in legacy_models.items():
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': old_model
}
}
migrated = self.migrator.migrate_claude_config(config_dict)
assert migrated['claude']['model'] == expected_new_model
def test_migrate_missing_claude_section(self):
"""Test migration when Claude section is completely missing"""
config_dict = {
'obsidian': {
'vault_path': '/test/vault'
}
}
migrated = self.migrator.migrate_claude_config(config_dict)
assert 'claude' in migrated
assert migrated['claude']['api_url'] == 'https://api.anthropic.com'
assert migrated['claude']['model'] == 'claude-3-5-sonnet-20241022'
def test_migrate_complete_configuration(self):
"""Test migration of complete configuration"""
config_dict = {
'obsidian': {
'vault_path': '/test/vault',
'rest_api': {
'url': 'https://localhost:27123',
'api_key': 'test-key'
}
},
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
migrated = self.migrator.migrate_configuration(config_dict)
# Check Claude migration
assert migrated['claude']['api_url'] == 'https://api.anthropic.com'
assert migrated['claude']['model'] == 'claude-3-sonnet-20240229'
# Check that other sections are added with defaults
assert 'journal' in migrated
assert 'output' in migrated
assert 'analysis' in migrated
assert 'logging' in migrated
def test_check_migration_needed(self):
"""Test checking if migration is needed"""
# Config that needs migration
config_needing_migration = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
assert self.migrator.check_migration_needed(config_needing_migration) is True
# Config that doesn't need migration - need all required fields
config_up_to_date = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'https://api.anthropic.com',
'model': 'claude-3-5-sonnet-20241022',
'max_tokens': 4096,
'temperature': 0.7
},
'journal': {
'daily_notes_folder': 'Daily',
'date_format': 'YYYY-MM-DD',
'file_extension': '.md'
},
'output': {
'experiences_folder': 'Knowledge/Experiences',
'lessons_folder': 'Knowledge/Lessons',
'tasks_folder': 'Tasks/Daily',
'problems_folder': 'Knowledge/Problems',
'achievements_folder': 'Knowledge/Achievements',
'improvements_folder': 'Knowledge/Improvements'
},
'analysis': {
'categories': [],
'extraction_rules': {}
},
'logging': {
'level': 'INFO',
'file': 'logs/journal_organizer.log'
}
}
assert self.migrator.check_migration_needed(config_up_to_date) is False
def test_get_migration_preview(self):
"""Test getting migration preview"""
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet'
}
}
preview = self.migrator.get_migration_preview(config_dict)
assert len(preview) > 0
assert any('api_url' in action for action in preview)
assert any('claude-3-sonnet' in action and 'claude-3-sonnet-20240229' in action for action in preview)
def test_get_supported_model_names(self):
"""Test getting supported model names"""
supported_models = self.migrator.get_supported_model_names()
# Should include current models
assert 'claude-3-5-sonnet-20241022' in supported_models
assert 'claude-3-opus-20240229' in supported_models
# Should include legacy models
assert 'claude-3-sonnet' in supported_models
assert 'sonnet' in supported_models
class TestConfigurationValidator:
"""Test ConfigurationValidator comprehensive validation"""
def setup_method(self):
"""Set up test fixtures"""
self.validator = ConfigurationValidator()
self.temp_dir = Path(tempfile.mkdtemp())
# Create a test vault directory
self.test_vault = self.temp_dir / 'test_vault'
self.test_vault.mkdir()
(self.test_vault / '.obsidian').mkdir()
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
def create_test_config_file(self, config_data: Dict[str, Any]) -> Path:
"""Create a test configuration file"""
config_file = self.temp_dir / 'test_config.yaml'
with config_file.open('w') as f:
yaml.dump(config_data, f)
return config_file
def test_load_and_validate_valid_config(self):
"""Test loading and validating a valid configuration"""
config_data = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api': {
'url': 'https://localhost:27123',
'api_key': 'test-api-key',
'verify_ssl': False
}
},
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'https://api.anthropic.com',
'model': 'claude-3-5-sonnet-20241022'
}
}
config_file = self.create_test_config_file(config_data)
system_config = self.validator.load_and_validate_config(config_file)
assert isinstance(system_config, SystemConfig)
assert system_config.claude.api_url == 'https://api.anthropic.com'
assert system_config.claude.model == 'claude-3-5-sonnet-20241022'
# Path resolution may add /private prefix on macOS, so check if paths resolve to same location
assert Path(system_config.obsidian.vault_path).resolve() == self.test_vault.resolve()
def test_load_and_validate_with_migration(self):
"""Test loading configuration that needs migration"""
config_data = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api': {
'url': 'https://localhost:27123',
'api_key': 'test-api-key'
}
},
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
config_file = self.create_test_config_file(config_data)
system_config = self.validator.load_and_validate_config(config_file)
# Should have migrated the model name
assert system_config.claude.model == 'claude-3-sonnet-20240229'
# Should have added default api_url
assert system_config.claude.api_url == 'https://api.anthropic.com'
def test_load_and_validate_with_env_vars(self):
"""Test loading configuration with environment variables"""
os.environ['TEST_CLAUDE_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
os.environ['TEST_VAULT_PATH'] = str(self.test_vault)
config_data = {
'obsidian': {
'vault_path': '${TEST_VAULT_PATH}',
'rest_api': {
'url': 'https://localhost:27123',
'api_key': 'test-api-key'
}
},
'claude': {
'api_key': '${TEST_CLAUDE_KEY}',
'api_url': '${CLAUDE_API_URL:-https://api.anthropic.com}'
}
}
config_file = self.create_test_config_file(config_data)
system_config = self.validator.load_and_validate_config(config_file)
assert system_config.claude.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
# Path resolution may add /private prefix on macOS, so check if paths resolve to same location
assert Path(system_config.obsidian.vault_path).resolve() == self.test_vault.resolve()
assert system_config.claude.api_url == 'https://api.anthropic.com'
# Clean up
del os.environ['TEST_CLAUDE_KEY']
del os.environ['TEST_VAULT_PATH']
def test_validation_error_handling(self):
"""Test validation error handling"""
config_data = {
'obsidian': {
'vault_path': '/nonexistent/path',
'rest_api': {
'url': 'invalid-url',
'api_key': 'test-key'
}
},
'claude': {
'api_key': 'invalid-key',
'model': 'invalid-model'
}
}
config_file = self.create_test_config_file(config_data)
with pytest.raises(ValueError, match="Configuration validation failed"):
self.validator.load_and_validate_config(config_file)
def test_validate_api_keys(self):
"""Test API key validation"""
# Create a valid system config for testing
system_config = SystemConfig(
obsidian=ObsidianConfig(
vault_path=str(self.test_vault),
rest_api=ObsidianRestAPIConfig(
url='https://localhost:27123',
api_key='test-api-key'
)
),
claude=ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890'
)
)
issues = self.validator.validate_api_keys(system_config)
# Should have no issues with valid keys
assert len(issues) == 0
def test_validate_api_keys_with_env_vars(self):
"""Test API key validation with environment variable placeholders"""
# Skip this test as it requires complex mocking of pydantic validation
pytest.skip("Environment variable validation requires complex setup")
@pytest.mark.asyncio
class TestClaudeAPIClient:
"""Test ClaudeAPIClient functionality"""
def setup_method(self):
"""Set up test fixtures"""
self.config = ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890',
api_url='https://api.anthropic.com',
model='claude-3-5-sonnet-20241022'
)
@patch('claude_api_client.AsyncAnthropic')
@patch('claude_api_client.aiohttp')
def test_client_initialization(self, mock_aiohttp, mock_anthropic):
"""Test Claude API client initialization"""
client = ClaudeAPIClient(self.config)
assert client.base_url == 'https://api.anthropic.com'
assert client.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert client.model == 'claude-3-5-sonnet-20241022'
# Should have called AsyncAnthropic constructor
mock_anthropic.assert_called_once()
@patch('claude_api_client.AsyncAnthropic')
@patch('claude_api_client.aiohttp')
def test_client_with_custom_url(self, mock_aiohttp, mock_anthropic):
"""Test client initialization with custom API URL"""
custom_config = ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890',
api_url='https://custom-api.example.com',
model='claude-3-5-sonnet-20241022'
)
client = ClaudeAPIClient(custom_config)
assert client.base_url == 'https://custom-api.example.com'
# Should have called AsyncAnthropic with custom base_url
mock_anthropic.assert_called_once()
call_args = mock_anthropic.call_args
assert call_args[1]['base_url'] == 'https://custom-api.example.com'
@patch('claude_api_client.AsyncAnthropic')
@patch('claude_api_client.aiohttp')
def test_get_client_info(self, mock_aiohttp, mock_anthropic):
"""Test getting client configuration information"""
client = ClaudeAPIClient(self.config)
info = client.get_client_info()
assert info['api_url'] == 'https://api.anthropic.com'
assert info['model'] == 'claude-3-5-sonnet-20241022'
assert info['max_tokens'] == 4096
assert info['temperature'] == 0.7
assert info['is_custom_endpoint'] is False
@patch('claude_api_client.AsyncAnthropic')
@patch('claude_api_client.aiohttp')
def test_get_client_info_custom_endpoint(self, mock_aiohttp, mock_anthropic):
"""Test getting client info for custom endpoint"""
custom_config = ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890',
api_url='https://custom-api.example.com'
)
client = ClaudeAPIClient(custom_config)
info = client.get_client_info()
assert info['is_custom_endpoint'] is True
assert info['api_url'] == 'https://custom-api.example.com'