Initial project setup: Obsidian intelligent journal organizer

- Add core agent architecture with Command + Skill pattern
- Implement Claude API integration for content analysis
- Add Obsidian REST API integration for vault operations
- Create conversational interface (v2.0) with natural language processing
- Add comprehensive configuration management and validation
- Include project documentation and developer guides
- Set up testing framework with unit, integration, and property tests
- Add Kiro specs for Claude API configuration and code quality improvements
- Configure project steering files for development guidelines
This commit is contained in:
windyboy
2025-12-31 17:55:10 +08:00
parent 3200ad3dd5
commit f7e54692a9
67 changed files with 23088 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Integration tests package
@@ -0,0 +1,659 @@
"""
Backward compatibility validation tests
Tests that existing configuration files continue to work, no breaking changes to existing functionality,
and migration messages are appropriate.
Requirements: 3.1, 3.2, 3.3, 3.4
"""
import os
import pytest
import tempfile
import yaml
from pathlib import Path
from typing import Dict, Any
from unittest.mock import patch, Mock
# Import the modules we're testing
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from config import Config
from config_validation import SystemConfig, ConfigurationValidator
from configuration_loader import ConfigurationLoader
from configuration_migrator import ConfigurationMigrator
class TestLegacyConfigurationFiles:
"""Test that existing configuration files continue to work"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
self.test_vault = self.temp_dir / 'test_vault'
self.test_vault.mkdir()
(self.test_vault / '.obsidian').mkdir()
# Store original environment variables
self.original_env = {}
if 'ANTHROPIC_API_KEY' in os.environ:
self.original_env['ANTHROPIC_API_KEY'] = os.environ['ANTHROPIC_API_KEY']
# Set a test API key for validation
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
# Restore original environment variables
if 'ANTHROPIC_API_KEY' in self.original_env:
os.environ['ANTHROPIC_API_KEY'] = self.original_env['ANTHROPIC_API_KEY']
elif 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
def create_legacy_config_file(self, config_data: Dict[str, Any]) -> Path:
"""Create a legacy configuration file"""
config_file = self.temp_dir / 'legacy_config.yaml'
with config_file.open('w') as f:
yaml.dump(config_data, f)
return config_file
def test_minimal_legacy_config_loads(self):
"""Test that minimal legacy configuration loads successfully"""
# This represents a very basic legacy config that users might have
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-sonnet' # Legacy model name
}
}
config_file = self.create_legacy_config_file(legacy_config)
# Should load without errors
config = Config(str(config_file))
# Verify legacy values are preserved and migrated appropriately
assert config.claude.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert config.claude.model == 'claude-3-sonnet-20240229' # Should be migrated
assert config.claude.api_url == 'https://api.anthropic.com' # Should get default
assert Path(config.obsidian.vault_path).resolve() == self.test_vault.resolve()
def test_legacy_config_without_claude_section(self):
"""Test legacy config that doesn't have Claude section at all"""
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
}
}
config_file = self.create_legacy_config_file(legacy_config)
# Should load and add Claude section with defaults
config = Config(str(config_file))
# Should have Claude config with defaults
assert config.claude.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert config.claude.model == 'claude-3-5-sonnet-20241022'
assert config.claude.api_url == 'https://api.anthropic.com'
def test_legacy_config_with_old_structure(self):
"""Test legacy config with old obsidian structure"""
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key',
'verify_ssl': False
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-opus', # Legacy model name
'max_tokens': 2048,
'temperature': 0.5
},
'journal': {
'daily_notes_folder': 'Journal/Daily',
'date_format': 'YYYY-MM-DD'
}
}
config_file = self.create_legacy_config_file(legacy_config)
# Should load and migrate appropriately
config = Config(str(config_file))
# Verify all legacy values are preserved
assert config.claude.model == 'claude-3-opus-20240229' # Migrated
assert config.claude.max_tokens == 2048 # Preserved
assert config.claude.temperature == 0.5 # Preserved
assert config.journal.daily_notes_folder == 'Journal/Daily' # Preserved
assert config.obsidian.verify_ssl is False # Preserved
def test_legacy_config_with_missing_sections(self):
"""Test legacy config with missing sections gets defaults"""
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}'
}
# Missing journal, output, analysis, logging sections
}
config_file = self.create_legacy_config_file(legacy_config)
# Should load and add missing sections with defaults
config = Config(str(config_file))
# Should have all sections with defaults
assert config.journal.daily_notes_folder == 'Daily'
assert config.journal.date_format == 'YYYY-MM-DD'
assert config.output.experiences_folder == 'Knowledge/Experiences'
assert config.analysis.categories == []
assert config.logging.level == 'INFO'
def test_legacy_model_name_variations(self):
"""Test various legacy model name formats are migrated correctly"""
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',
'opus': 'claude-3-opus-20240229',
'haiku': 'claude-3-haiku-20240307'
}
for legacy_model, expected_model in legacy_models.items():
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': legacy_model
}
}
config_file = self.create_legacy_config_file(legacy_config)
config = Config(str(config_file))
assert config.claude.model == expected_model, f"Legacy model '{legacy_model}' should migrate to '{expected_model}'"
class TestNoBreakingChanges:
"""Test that no breaking changes exist in existing functionality"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
self.test_vault = self.temp_dir / 'test_vault'
self.test_vault.mkdir()
(self.test_vault / '.obsidian').mkdir()
# Set test API key
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
if 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
def create_config_file(self, config_data: Dict[str, Any]) -> Path:
"""Create a configuration file"""
config_file = self.temp_dir / 'config.yaml'
with config_file.open('w') as f:
yaml.dump(config_data, f)
return config_file
def test_config_class_interface_unchanged(self):
"""Test that Config class interface remains unchanged"""
config_data = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-5-sonnet-20241022'
}
}
config_file = self.create_config_file(config_data)
config = Config(str(config_file))
# Test that all expected attributes exist and work as before
assert hasattr(config, 'obsidian')
assert hasattr(config, 'claude')
assert hasattr(config, 'journal')
assert hasattr(config, 'output')
assert hasattr(config, 'analysis')
assert hasattr(config, 'logging')
# Test that methods still work
assert callable(config.get_daily_note_path)
assert callable(config.to_dict)
# Test method functionality
daily_path = config.get_daily_note_path('2024-01-01')
assert '2024-01-01' in daily_path
config_dict = config.to_dict()
assert isinstance(config_dict, dict)
assert 'obsidian' in config_dict
assert 'claude' in config_dict
def test_config_attribute_access_unchanged(self):
"""Test that config attribute access patterns remain unchanged"""
config_data = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-5-sonnet-20241022',
'max_tokens': 4096,
'temperature': 0.7
}
}
config_file = self.create_config_file(config_data)
config = Config(str(config_file))
# Test that all legacy attribute access patterns still work
assert config.obsidian.vault_path == str(self.test_vault)
assert config.obsidian.rest_api_url == 'https://localhost:27123'
assert config.obsidian.rest_api_key == 'test-key'
assert config.claude.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert config.claude.model == 'claude-3-5-sonnet-20241022'
assert config.claude.max_tokens == 4096
assert config.claude.temperature == 0.7
# Test new attributes are accessible
assert hasattr(config.claude, 'api_url')
assert config.claude.api_url == 'https://api.anthropic.com'
def test_config_validation_behavior_unchanged(self):
"""Test that config validation behavior remains the same for valid configs"""
valid_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-5-sonnet-20241022'
}
}
config_file = self.create_config_file(valid_config)
# Should load without errors (same as before)
config = Config(str(config_file))
assert config is not None
# Should still validate the same way
errors = config.get_validation_errors()
assert isinstance(errors, list)
def test_config_error_handling_unchanged(self):
"""Test that config error handling behavior remains unchanged"""
# Test with invalid vault path (should still raise appropriate error)
invalid_config = {
'obsidian': {
'vault_path': '/nonexistent/path',
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-5-sonnet-20241022'
}
}
config_file = self.create_config_file(invalid_config)
# Should still handle errors the same way (warnings, not exceptions for path issues)
config = Config(str(config_file))
errors = config.get_validation_errors()
assert len(errors) > 0 # Should have path validation warnings
class TestMigrationMessages:
"""Test that migration messages are appropriate and helpful"""
def setup_method(self):
"""Set up test fixtures"""
self.migrator = ConfigurationMigrator()
self.temp_dir = Path(tempfile.mkdtemp())
# Set test API key
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
if 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
def test_migration_needed_detection_accurate(self):
"""Test that migration detection is accurate"""
# 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
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_migration_preview_messages_helpful(self):
"""Test that migration preview messages are helpful and informative"""
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
preview = self.migrator.get_migration_preview(config_dict)
assert len(preview) > 0
# Should have informative messages about what will be changed
preview_text = '\n'.join(preview)
assert 'api_url' in preview_text.lower()
assert 'claude-3-sonnet' in preview_text
assert 'claude-3-sonnet-20240229' in preview_text
# Messages should be user-friendly
assert any('will be added' in msg or 'will be migrated' in msg or 'will be updated' in msg for msg in preview)
def test_migration_messages_include_rationale(self):
"""Test that migration messages include rationale for changes"""
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
# Missing api_url and using default model
}
}
preview = self.migrator.get_migration_preview(config_dict)
# Should explain why changes are being made
preview_text = '\n'.join(preview).lower()
assert 'default' in preview_text or 'missing' in preview_text
def test_migration_preserves_user_values(self):
"""Test that migration preserves user-specified values"""
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet', # Legacy model name
'max_tokens': 8192, # User-specified value
'temperature': 0.3 # User-specified value
}
}
migrated = self.migrator.migrate_claude_config(config_dict)
# Should preserve user values
assert migrated['claude']['max_tokens'] == 8192
assert migrated['claude']['temperature'] == 0.3
# Should migrate legacy values
assert migrated['claude']['model'] == 'claude-3-sonnet-20240229'
# Should add missing defaults
assert migrated['claude']['api_url'] == 'https://api.anthropic.com'
def test_migration_handles_partial_configs(self):
"""Test that migration handles partial configurations gracefully"""
partial_configs = [
# Only obsidian config
{
'obsidian': {
'vault_path': '/test/vault',
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
}
},
# Only claude config
{
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
}
},
# Empty config
{}
]
for partial_config in partial_configs:
# Should not raise errors
migrated = self.migrator.migrate_configuration(partial_config)
# Should have all required sections
assert 'claude' in migrated
assert 'journal' in migrated
assert 'output' in migrated
assert 'analysis' in migrated
assert 'logging' in migrated
# Claude section should have all required fields
assert 'api_key' in migrated['claude']
assert 'api_url' in migrated['claude']
assert 'model' in migrated['claude']
def test_supported_model_names_comprehensive(self):
"""Test that supported model names list is comprehensive"""
supported_models = self.migrator.get_supported_model_names()
# Should include current models
current_models = [
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-3-opus-20240229',
'claude-3-sonnet-20240229',
'claude-3-haiku-20240307'
]
for model in current_models:
assert model in supported_models, f"Current model '{model}' should be in supported list"
# Should include legacy models
legacy_models = [
'claude-3-sonnet',
'claude-3-opus',
'claude-3-haiku',
'sonnet',
'opus',
'haiku'
]
for model in legacy_models:
assert model in supported_models, f"Legacy model '{model}' should be in supported list"
@patch('builtins.print')
def test_migration_logging_appropriate(self, mock_print):
"""Test that migration produces appropriate logging messages"""
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
# Perform migration
migrated = self.migrator.migrate_claude_config(config_dict)
# Should have logged the migration
assert mock_print.called
# Check that the log message is informative
log_calls = [call[0][0] for call in mock_print.call_args_list]
log_text = ' '.join(log_calls)
assert 'claude-3-sonnet' in log_text
assert 'claude-3-sonnet-20240229' in log_text
assert 'migrated' in log_text.lower() or 'updated' in log_text.lower()
class TestConfigurationIntegration:
"""Test full configuration integration with backward compatibility"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
self.test_vault = self.temp_dir / 'test_vault'
self.test_vault.mkdir()
(self.test_vault / '.obsidian').mkdir()
# Set test API key
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
if 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
def create_config_file(self, config_data: Dict[str, Any]) -> Path:
"""Create a configuration file"""
config_file = self.temp_dir / 'config.yaml'
with config_file.open('w') as f:
yaml.dump(config_data, f)
return config_file
def test_end_to_end_legacy_config_loading(self):
"""Test end-to-end loading of legacy configuration"""
# Simulate a real legacy config file that a user might have
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key',
'verify_ssl': False
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'sonnet', # Very legacy model name
'max_tokens': 2048
},
'journal': {
'daily_notes_folder': 'Daily Notes',
'date_format': 'YYYY-MM-DD'
}
}
config_file = self.create_config_file(legacy_config)
# Load through the full Config class (end-to-end test)
config = Config(str(config_file))
# Verify everything works as expected
assert config.claude.model == 'claude-3-5-sonnet-20241022' # Migrated from 'sonnet'
assert config.claude.api_url == 'https://api.anthropic.com' # Added default
assert config.claude.max_tokens == 2048 # Preserved user value
assert config.journal.daily_notes_folder == 'Daily Notes' # Preserved
# Test that methods still work
daily_path = config.get_daily_note_path('2024-01-01')
assert 'Daily Notes' in daily_path
assert '2024-01-01' in daily_path
# Test serialization still works
config_dict = config.to_dict()
assert config_dict['claude']['model'] == 'claude-3-5-sonnet-20241022'
assert 'api_key' not in config_dict['claude'] # Should be sanitized
def test_config_reload_preserves_migration(self):
"""Test that config reload preserves migration results"""
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-opus' # Legacy model name
}
}
config_file = self.create_config_file(legacy_config)
config = Config(str(config_file))
# Verify initial migration
assert config.claude.model == 'claude-3-opus-20240229'
# Reload config
config.reload_config()
# Should still have migrated values
assert config.claude.model == 'claude-3-opus-20240229'
assert config.claude.api_url == 'https://api.anthropic.com'
if __name__ == "__main__":
# Run tests with pytest
pytest.main([__file__, "-v"])
@@ -0,0 +1,191 @@
"""
Integration tests for Claude API configuration system
Tests Skills with different Claude API configurations, environment variables, and backward compatibility
"""
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
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from config_validation import ClaudeAPIConfig
from configuration_loader import ConfigurationLoader
from configuration_migrator import ConfigurationMigrator
class TestEnvironmentVariableIntegration:
"""Test environment variable scenarios in configuration"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
self.loader = ConfigurationLoader()
self.migrator = ConfigurationMigrator()
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
# Clean up any test environment variables
test_vars = ['TEST_CLAUDE_API_KEY', 'TEST_CLAUDE_API_URL', 'TEST_CLAUDE_MODEL']
for var in test_vars:
if var in os.environ:
del os.environ[var]
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_environment_variable_expansion(self):
"""Test environment variable expansion"""
# Set up environment variables
os.environ['TEST_CLAUDE_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
os.environ['TEST_CLAUDE_API_URL'] = 'https://custom-api.example.com'
config_data = {
'claude': {
'api_key': '${TEST_CLAUDE_API_KEY}',
'api_url': '${TEST_CLAUDE_API_URL}',
'model': 'claude-3-5-sonnet-20241022'
}
}
expanded = self.loader.expand_environment_variables(config_data)
assert expanded['claude']['api_key'] == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert expanded['claude']['api_url'] == 'https://custom-api.example.com'
assert expanded['claude']['model'] == 'claude-3-5-sonnet-20241022'
def test_environment_variable_defaults(self):
"""Test environment variable expansion with defaults"""
config_data = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'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)
# Should use defaults since env vars are not set
assert expanded['claude']['api_url'] == 'https://api.anthropic.com'
assert expanded['claude']['model'] == 'claude-3-5-sonnet-20241022'
class TestBackwardCompatibilityIntegration:
"""Test backward compatibility with existing setups"""
def setup_method(self):
"""Set up test fixtures"""
self.migrator = ConfigurationMigrator()
def test_legacy_model_migration(self):
"""Test migration of legacy model names"""
legacy_config = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
migrated = self.migrator.migrate_claude_config(legacy_config)
# Should migrate to new model name
assert migrated['claude']['model'] == 'claude-3-sonnet-20240229'
# Should add default API URL
assert migrated['claude']['api_url'] == 'https://api.anthropic.com'
def test_migration_needed_detection(self):
"""Test detection of configurations that need migration"""
# Config that needs migration
legacy_config = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-opus' # Legacy model name
}
}
assert self.migrator.check_migration_needed(legacy_config) is True
# Config that doesn't need migration
modern_config = {
'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(modern_config) is False
class TestConfigurationValidation:
"""Test configuration validation scenarios"""
def test_claude_api_config_validation(self):
"""Test ClaudeAPIConfig validation"""
# Valid configuration
config = ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890',
api_url='https://custom-api.example.com',
model='claude-3-5-sonnet-20241022'
)
assert config.api_key.startswith('sk-ant-')
assert config.api_url == 'https://custom-api.example.com'
assert config.model == 'claude-3-5-sonnet-20241022'
def test_invalid_configuration_handling(self):
"""Test handling of invalid configurations"""
# Invalid API key format
with pytest.raises(ValueError, match="Claude API key should start with"):
ClaudeAPIConfig(api_key='invalid-key')
# Invalid URL format
with pytest.raises(ValueError, match="Invalid URL format"):
ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890',
api_url='not-a-url'
)
# Invalid model name
with pytest.raises(ValueError, match="Invalid model name"):
ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890',
model='invalid-model'
)
+516
View File
@@ -0,0 +1,516 @@
"""
Integration tests for Claude Skills.
Tests Skills with mocked API responses to verify AI integration functionality.
"""
import pytest
import json
from unittest.mock import AsyncMock, patch, Mock
from agent_core import CommandContext, SkillResult
from skills.claude_skill import ClaudeAnalyzeSkill, ClaudeTransformSkill
class TestClaudeAnalyzeSkill:
"""Integration tests for ClaudeAnalyzeSkill"""
@pytest.fixture
def skill(self):
"""Create ClaudeAnalyzeSkill instance"""
return ClaudeAnalyzeSkill()
@pytest.fixture
def context(self, sample_config):
"""Create command context with analysis parameters"""
return CommandContext(
command_name="analyze",
args={
"content": """# 2024-01-15 Daily Journal
## 今天的经历
- 完成了项目的重要里程碑
- 与团队进行了有效的沟通
## 学到的东西
- 学会了新的Python异步编程技巧
- 理解了更好的错误处理模式
## 遇到的问题
- API调用偶尔超时
- 配置文件格式需要改进
## 明天的计划
- 优化API调用的重试机制
- 更新文档
""",
"analysis_type": "extract_experiences"
},
config=sample_config
)
@pytest.mark.asyncio
async def test_analyze_journal_success(self, skill, context):
"""Test successful journal analysis"""
mock_response = {
"experiences": [
{
"title": "项目里程碑完成",
"description": "成功完成了项目的重要里程碑,展现了良好的项目管理能力",
"category": "项目管理",
"importance": "high"
},
{
"title": "团队沟通改进",
"description": "与团队进行了有效的沟通,提升了协作效率",
"category": "团队协作",
"importance": "medium"
}
],
"lessons": [
{
"title": "Python异步编程",
"description": "学会了新的Python异步编程技巧,提升了代码效率",
"category": "技术学习",
"application": "可以应用到当前项目的API调用优化中"
}
],
"problems": [
{
"title": "API调用超时",
"description": "API调用偶尔出现超时问题",
"severity": "medium",
"suggested_solution": "实现重试机制和超时处理"
}
]
}
# Mock the Claude API client
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock the messages.create method
mock_message = Mock()
mock_message.content = [Mock(text=json.dumps(mock_response, ensure_ascii=False))]
mock_client.messages.create.return_value = mock_message
result = await skill.execute(context)
assert result.success is True
assert "experiences" in result.data
assert "lessons" in result.data
assert "problems" in result.data
assert len(result.data["experiences"]) == 2
assert len(result.data["lessons"]) == 1
assert len(result.data["problems"]) == 1
# Verify API was called with correct parameters
mock_client.messages.create.assert_called_once()
call_args = mock_client.messages.create.call_args
assert call_args[1]["model"] == "claude-3-5-sonnet-20241022"
assert call_args[1]["max_tokens"] == 4096
@pytest.mark.asyncio
async def test_analyze_empty_content(self, skill, sample_config):
"""Test analysis with empty content"""
context = CommandContext(
command_name="analyze",
args={"content": "", "analysis_type": "extract_experiences"},
config=sample_config
)
result = await skill.execute(context)
assert result.success is False
assert "content" in result.error.lower()
@pytest.mark.asyncio
async def test_analyze_api_error(self, skill, context):
"""Test handling of Claude API errors"""
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock API error
mock_client.messages.create.side_effect = Exception("API rate limit exceeded")
result = await skill.execute(context)
assert result.success is False
assert "api" in result.error.lower() or "rate limit" in result.error.lower()
@pytest.mark.asyncio
async def test_analyze_invalid_json_response(self, skill, context):
"""Test handling of invalid JSON response from Claude"""
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock invalid JSON response
mock_message = Mock()
mock_message.content = [Mock(text="Invalid JSON response")]
mock_client.messages.create.return_value = mock_message
result = await skill.execute(context)
assert result.success is False
assert "json" in result.error.lower() or "parse" in result.error.lower()
@pytest.mark.asyncio
async def test_analyze_different_types(self, skill, sample_config):
"""Test different analysis types"""
analysis_types = ["extract_experiences", "extract_lessons", "extract_problems", "summarize"]
for analysis_type in analysis_types:
context = CommandContext(
command_name="analyze",
args={
"content": "Sample journal content for testing",
"analysis_type": analysis_type
},
config=sample_config
)
mock_response = {"result": f"Analysis result for {analysis_type}"}
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
mock_message = Mock()
mock_message.content = [Mock(text=json.dumps(mock_response))]
mock_client.messages.create.return_value = mock_message
result = await skill.execute(context)
assert result.success is True
assert result.data["analysis_type"] == analysis_type
class TestClaudeTransformSkill:
"""Integration tests for ClaudeTransformSkill"""
@pytest.fixture
def skill(self):
"""Create ClaudeTransformSkill instance"""
return ClaudeTransformSkill()
@pytest.fixture
def context(self, sample_config):
"""Create command context with transformation parameters"""
return CommandContext(
command_name="transform",
args={
"content": {
"title": "项目里程碑完成",
"description": "成功完成了项目的重要里程碑",
"category": "项目管理"
},
"transform_type": "create_experience_note",
"target_format": "markdown"
},
config=sample_config
)
@pytest.mark.asyncio
async def test_transform_to_markdown_success(self, skill, context):
"""Test successful content transformation to markdown"""
mock_response = """# 项目里程碑完成
## 经验描述
成功完成了项目的重要里程碑,这次经历展现了良好的项目管理能力和团队协作精神。
## 关键要点
- 项目管理技能得到提升
- 团队协作效率显著改善
- 里程碑按时完成
## 应用场景
这个经验可以应用到未来的项目管理中,特别是在设定和跟踪项目里程碑方面。
## 相关标签
#项目管理 #里程碑 #团队协作
---
*创建时间: 2024-01-15*
*来源: 日记整理*
"""
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
mock_message = Mock()
mock_message.content = [Mock(text=mock_response)]
mock_client.messages.create.return_value = mock_message
result = await skill.execute(context)
assert result.success is True
assert result.data["transformed_content"] == mock_response
assert result.data["transform_type"] == "create_experience_note"
assert result.data["target_format"] == "markdown"
# Verify the content contains expected markdown elements
assert "# 项目里程碑完成" in result.data["transformed_content"]
assert "## 经验描述" in result.data["transformed_content"]
assert "#项目管理" in result.data["transformed_content"]
@pytest.mark.asyncio
async def test_transform_different_types(self, skill, sample_config):
"""Test different transformation types"""
transform_types = [
"create_experience_note",
"create_lesson_note",
"create_problem_note",
"create_summary"
]
for transform_type in transform_types:
context = CommandContext(
command_name="transform",
args={
"content": {"title": "Test", "description": "Test content"},
"transform_type": transform_type,
"target_format": "markdown"
},
config=sample_config
)
mock_response = f"# Transformed Content\n\nContent for {transform_type}"
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
mock_message = Mock()
mock_message.content = [Mock(text=mock_response)]
mock_client.messages.create.return_value = mock_message
result = await skill.execute(context)
assert result.success is True
assert result.data["transform_type"] == transform_type
@pytest.mark.asyncio
async def test_transform_missing_content(self, skill, sample_config):
"""Test transformation with missing content"""
context = CommandContext(
command_name="transform",
args={
"transform_type": "create_experience_note",
"target_format": "markdown"
# Missing content
},
config=sample_config
)
result = await skill.execute(context)
assert result.success is False
assert "content" in result.error.lower()
@pytest.mark.asyncio
async def test_transform_api_error(self, skill, context):
"""Test handling of Claude API errors during transformation"""
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock API error
mock_client.messages.create.side_effect = Exception("API authentication failed")
result = await skill.execute(context)
assert result.success is False
assert "api" in result.error.lower() or "authentication" in result.error.lower()
class TestClaudeSkillsIntegration:
"""Integration tests combining Claude skills"""
@pytest.mark.asyncio
async def test_analyze_then_transform_workflow(self, sample_config):
"""Test complete analyze-then-transform workflow"""
analyze_skill = ClaudeAnalyzeSkill()
transform_skill = ClaudeTransformSkill()
# Step 1: Analyze journal content
analyze_context = CommandContext(
command_name="analyze",
args={
"content": sample_journal_content,
"analysis_type": "extract_experiences"
},
config=sample_config
)
# Step 2: Transform extracted experience to note
experience_data = {
"title": "项目里程碑完成",
"description": "成功完成了项目的重要里程碑",
"category": "项目管理",
"importance": "high"
}
transform_context = CommandContext(
command_name="transform",
args={
"content": experience_data,
"transform_type": "create_experience_note",
"target_format": "markdown"
},
config=sample_config
)
# Mock responses
analyze_response = {
"experiences": [experience_data],
"lessons": [],
"problems": []
}
transform_response = """# 项目里程碑完成
## 经验描述
成功完成了项目的重要里程碑
## 分类
项目管理
## 重要程度
#项目管理 #里程碑
"""
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock analyze response
mock_analyze_message = Mock()
mock_analyze_message.content = [Mock(text=json.dumps(analyze_response, ensure_ascii=False))]
# Mock transform response
mock_transform_message = Mock()
mock_transform_message.content = [Mock(text=transform_response)]
# Set up side_effect to return different responses for different calls
mock_client.messages.create.side_effect = [mock_analyze_message, mock_transform_message]
# Execute analyze
analyze_result = await analyze_skill.execute(analyze_context)
assert analyze_result.success is True
assert len(analyze_result.data["experiences"]) == 1
# Execute transform using analyze result
transform_result = await transform_skill.execute(transform_context)
assert transform_result.success is True
assert "项目里程碑完成" in transform_result.data["transformed_content"]
# Verify both API calls were made
assert mock_client.messages.create.call_count == 2
@pytest.mark.asyncio
async def test_batch_analysis_and_transformation(self, sample_config):
"""Test batch processing of multiple content pieces"""
analyze_skill = ClaudeAnalyzeSkill()
transform_skill = ClaudeTransformSkill()
# Multiple journal entries to process
journal_entries = [
"今天学会了新的编程技巧",
"解决了一个复杂的技术问题",
"与客户进行了重要的项目讨论"
]
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock responses for each entry
mock_responses = []
for i, entry in enumerate(journal_entries):
analyze_response = {
"experiences": [{
"title": f"Experience {i+1}",
"description": entry,
"category": "学习"
}]
}
transform_response = f"# Experience {i+1}\n\n{entry}\n\n#学习"
mock_analyze_message = Mock()
mock_analyze_message.content = [Mock(text=json.dumps(analyze_response, ensure_ascii=False))]
mock_transform_message = Mock()
mock_transform_message.content = [Mock(text=transform_response)]
mock_responses.extend([mock_analyze_message, mock_transform_message])
mock_client.messages.create.side_effect = mock_responses
# Process each entry
results = []
for entry in journal_entries:
# Analyze
analyze_context = CommandContext(
command_name="analyze",
args={"content": entry, "analysis_type": "extract_experiences"},
config=sample_config
)
analyze_result = await analyze_skill.execute(analyze_context)
# Transform
if analyze_result.success and analyze_result.data["experiences"]:
experience = analyze_result.data["experiences"][0]
transform_context = CommandContext(
command_name="transform",
args={
"content": experience,
"transform_type": "create_experience_note",
"target_format": "markdown"
},
config=sample_config
)
transform_result = await transform_skill.execute(transform_context)
results.append((analyze_result, transform_result))
# Verify all entries were processed successfully
assert len(results) == len(journal_entries)
for analyze_result, transform_result in results:
assert analyze_result.success is True
assert transform_result.success is True
# Verify correct number of API calls (2 per entry: analyze + transform)
assert mock_client.messages.create.call_count == len(journal_entries) * 2
# Sample journal content for testing
sample_journal_content = """# 2024-01-15 Daily Journal
## 今天的经历
- 完成了项目的重要里程碑
- 与团队进行了有效的沟通
- 参加了技术分享会议
## 学到的东西
- 学会了新的Python异步编程技巧
- 理解了更好的错误处理模式
- 掌握了新的项目管理方法
## 遇到的问题
- API调用偶尔超时
- 配置文件格式需要改进
- 团队沟通中存在信息不对称
## 明天的计划
- 优化API调用的重试机制
- 更新文档
- 组织团队同步会议
"""
@@ -0,0 +1,431 @@
"""
Integration tests for conversational agent flow.
Tests the v2.0 conversational interface with natural language processing.
"""
import pytest
import json
from unittest.mock import AsyncMock, patch, Mock
from aioresponses import aioresponses
from agent_core import CommandContext, SkillResult
from conversation.conversational_agent import ConversationalAgent
from conversation.conversation_state import ConversationState
from conversation.intent_understanding import IntentUnderstanding
from conversation.response_generator import ResponseGenerator
class TestConversationalAgent:
"""Integration tests for ConversationalAgent"""
@pytest.fixture
def agent(self, sample_config):
"""Create ConversationalAgent instance"""
return ConversationalAgent(sample_config)
@pytest.fixture
def sample_user_inputs(self):
"""Sample user inputs for testing"""
return [
"整理今天的日记",
"organize today's journal",
"分析2024年1月15日的日记",
"help me organize my notes from yesterday",
"今天学到了什么?",
"what did I learn today?",
"整理昨天的经验和教训"
]
@pytest.mark.asyncio
async def test_conversational_agent_basic_flow(self, agent):
"""Test basic conversational flow"""
user_input = "整理今天的日记"
# Mock the underlying organize command execution
with patch.object(agent.agent, 'execute_command') as mock_execute:
mock_result = SkillResult(
success=True,
data={
"summary": "Successfully organized journal",
"created_notes": [
{"type": "experience", "title": "Test Experience", "path": "Knowledge/Experiences/test.md"}
]
},
message="Journal organized successfully"
)
mock_execute.return_value = mock_result
response = await agent.process_message(user_input)
assert response is not None
assert "成功" in response or "successfully" in response.lower()
mock_execute.assert_called_once()
# Verify the command was called with correct parameters
call_args = mock_execute.call_args
assert call_args[0][0] == "organize" # Command name
@pytest.mark.asyncio
async def test_conversational_agent_with_date_extraction(self, agent):
"""Test conversational agent with date parameter extraction"""
user_input = "分析2024年1月15日的日记"
with patch.object(agent.agent, 'execute_command') as mock_execute:
mock_result = SkillResult(success=True, data={}, message="Analysis completed")
mock_execute.return_value = mock_result
response = await agent.process_message(user_input)
assert response is not None
mock_execute.assert_called_once()
# Verify date was extracted and passed
call_args = mock_execute.call_args
assert "date" in call_args[1]["args"] # Should have extracted date
@pytest.mark.asyncio
async def test_conversational_agent_error_handling(self, agent):
"""Test conversational agent error handling"""
user_input = "整理今天的日记"
with patch.object(agent.agent, 'execute_command') as mock_execute:
mock_result = SkillResult(
success=False,
error="Journal file not found",
message="Failed to organize journal"
)
mock_execute.return_value = mock_result
response = await agent.process_message(user_input)
assert response is not None
assert "错误" in response or "error" in response.lower() or "failed" in response.lower()
@pytest.mark.asyncio
async def test_conversational_agent_unknown_intent(self, agent):
"""Test conversational agent with unknown intent"""
user_input = "今天天气怎么样?" # Weather question, not related to journal organization
response = await agent.process_message(user_input)
assert response is not None
assert "不理解" in response or "不明白" in response or "help" in response.lower()
@pytest.mark.asyncio
async def test_conversational_agent_help_request(self, agent):
"""Test conversational agent help functionality"""
help_inputs = ["help", "帮助", "你能做什么?", "what can you do?"]
for user_input in help_inputs:
response = await agent.process_message(user_input)
assert response is not None
assert "整理" in response or "organize" in response.lower()
assert "日记" in response or "journal" in response.lower()
@pytest.mark.asyncio
async def test_conversational_agent_multiple_turns(self, agent):
"""Test multi-turn conversation"""
conversation_turns = [
("你好", "greeting"),
("整理今天的日记", "organize"),
("谢谢", "thanks")
]
for user_input, expected_intent in conversation_turns:
if expected_intent == "organize":
with patch.object(agent.agent, 'execute_command') as mock_execute:
mock_result = SkillResult(success=True, data={}, message="Success")
mock_execute.return_value = mock_result
response = await agent.process_message(user_input)
else:
response = await agent.process_message(user_input)
assert response is not None
assert len(response) > 0
class TestIntentUnderstanding:
"""Integration tests for IntentUnderstanding"""
@pytest.fixture
def intent_processor(self):
"""Create IntentUnderstanding instance"""
return IntentUnderstanding()
def test_organize_intent_detection(self, intent_processor):
"""Test detection of organize intents"""
organize_inputs = [
"整理今天的日记",
"organize today's journal",
"分析我的日记",
"help me organize my notes",
"整理昨天的笔记"
]
for user_input in organize_inputs:
intent = intent_processor.understand_intent(user_input)
assert intent["action"] == "organize"
assert "command" in intent
assert intent["command"] == "organize"
def test_date_parameter_extraction(self, intent_processor):
"""Test extraction of date parameters"""
date_inputs = [
("整理2024年1月15日的日记", "2024-01-15"),
("analyze journal from yesterday", "yesterday"),
("organize today's notes", "today"),
("分析昨天的日记", "yesterday")
]
for user_input, expected_date in date_inputs:
intent = intent_processor.understand_intent(user_input)
if expected_date in ["today", "yesterday"]:
# These should be converted to actual dates
assert "date" in intent["parameters"]
else:
assert intent["parameters"].get("date") == expected_date
def test_help_intent_detection(self, intent_processor):
"""Test detection of help intents"""
help_inputs = [
"help",
"帮助",
"你能做什么?",
"what can you do?",
"how to use this?"
]
for user_input in help_inputs:
intent = intent_processor.understand_intent(user_input)
assert intent["action"] == "help"
def test_unknown_intent_handling(self, intent_processor):
"""Test handling of unknown intents"""
unknown_inputs = [
"今天天气怎么样?",
"what's the weather like?",
"计算1+1等于多少",
"play music"
]
for user_input in unknown_inputs:
intent = intent_processor.understand_intent(user_input)
assert intent["action"] == "unknown"
assert "confidence" in intent
assert intent["confidence"] < 0.5 # Low confidence for unknown intents
class TestResponseGenerator:
"""Integration tests for ResponseGenerator"""
@pytest.fixture
def response_generator(self):
"""Create ResponseGenerator instance"""
return ResponseGenerator()
def test_success_response_generation(self, response_generator):
"""Test generation of success responses"""
result = SkillResult(
success=True,
data={
"summary": "Successfully organized journal",
"created_notes": [
{"type": "experience", "title": "Project Milestone", "path": "Knowledge/Experiences/milestone.md"},
{"type": "lesson", "title": "Python Tips", "path": "Knowledge/Lessons/python.md"}
]
},
message="Journal organized successfully"
)
response = response_generator.generate_response(result, "organize")
assert response is not None
assert "成功" in response or "successfully" in response.lower()
assert "2" in response # Should mention number of notes created
assert "经验" in response or "experience" in response.lower()
assert "教训" in response or "lesson" in response.lower()
def test_error_response_generation(self, response_generator):
"""Test generation of error responses"""
result = SkillResult(
success=False,
error="Journal file not found for date 2024-01-15",
message="Failed to organize journal"
)
response = response_generator.generate_response(result, "organize")
assert response is not None
assert "错误" in response or "error" in response.lower() or "失败" in response
assert "2024-01-15" in response # Should include the problematic date
def test_help_response_generation(self, response_generator):
"""Test generation of help responses"""
response = response_generator.generate_help_response()
assert response is not None
assert "整理" in response or "organize" in response.lower()
assert "日记" in response or "journal" in response.lower()
assert "命令" in response or "command" in response.lower()
def test_unknown_intent_response(self, response_generator):
"""Test generation of unknown intent responses"""
response = response_generator.generate_unknown_response("今天天气怎么样?")
assert response is not None
assert "不理解" in response or "不明白" in response or "understand" in response.lower()
assert "帮助" in response or "help" in response.lower()
class TestConversationState:
"""Integration tests for ConversationState"""
@pytest.fixture
def conversation_state(self):
"""Create ConversationState instance"""
return ConversationState()
def test_conversation_history_tracking(self, conversation_state):
"""Test conversation history tracking"""
# Add some conversation turns
conversation_state.add_turn("user", "整理今天的日记")
conversation_state.add_turn("assistant", "好的,我来帮您整理今天的日记。")
conversation_state.add_turn("user", "谢谢")
conversation_state.add_turn("assistant", "不客气!还有其他需要帮助的吗?")
history = conversation_state.get_history()
assert len(history) == 4
assert history[0]["role"] == "user"
assert history[0]["content"] == "整理今天的日记"
assert history[1]["role"] == "assistant"
assert history[-1]["role"] == "assistant"
def test_context_management(self, conversation_state):
"""Test conversation context management"""
# Set some context
conversation_state.set_context("last_command", "organize")
conversation_state.set_context("last_date", "2024-01-15")
conversation_state.set_context("user_preference", "detailed_summary")
# Retrieve context
assert conversation_state.get_context("last_command") == "organize"
assert conversation_state.get_context("last_date") == "2024-01-15"
assert conversation_state.get_context("user_preference") == "detailed_summary"
assert conversation_state.get_context("nonexistent") is None
def test_conversation_reset(self, conversation_state):
"""Test conversation reset functionality"""
# Add some data
conversation_state.add_turn("user", "test message")
conversation_state.set_context("test_key", "test_value")
# Verify data exists
assert len(conversation_state.get_history()) == 1
assert conversation_state.get_context("test_key") == "test_value"
# Reset conversation
conversation_state.reset()
# Verify data is cleared
assert len(conversation_state.get_history()) == 0
assert conversation_state.get_context("test_key") is None
class TestFullConversationalFlow:
"""End-to-end integration tests for the complete conversational flow"""
@pytest.mark.asyncio
async def test_complete_organize_conversation(self, sample_config, sample_journal_content):
"""Test complete conversation flow for journal organization"""
agent = ConversationalAgent(sample_config)
# Mock all external dependencies
with aioresponses() as m:
# Mock Obsidian API
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": sample_journal_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": len(sample_journal_content)}
},
status=200
)
# Mock note creation
m.put(
"https://localhost:27123/vault/Knowledge/Experiences/test.md",
payload={"path": "Knowledge/Experiences/test.md", "stat": {}},
status=200
)
# Mock Claude API
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock analysis response
analyze_response = {
"experiences": [{"title": "Test Experience", "description": "Test", "category": "Test"}],
"lessons": [],
"problems": [],
"achievements": []
}
mock_analyze_message = Mock()
mock_analyze_message.content = [Mock(text=json.dumps(analyze_response, ensure_ascii=False))]
mock_transform_message = Mock()
mock_transform_message.content = [Mock(text="# Test Experience\n\nTest content")]
mock_client.messages.create.side_effect = [mock_analyze_message, mock_transform_message]
# Simulate conversation
conversation_turns = [
"你好",
"整理今天的日记",
"谢谢你的帮助"
]
responses = []
for user_input in conversation_turns:
response = await agent.process_message(user_input)
responses.append(response)
assert response is not None
assert len(response) > 0
# Verify conversation flow
assert "你好" in responses[0] or "hello" in responses[0].lower() # Greeting response
assert "成功" in responses[1] or "successfully" in responses[1].lower() # Success response
assert "不客气" in responses[2] or "welcome" in responses[2].lower() # Thanks response
@pytest.mark.asyncio
async def test_error_recovery_conversation(self, sample_config):
"""Test conversation flow with error recovery"""
agent = ConversationalAgent(sample_config)
with aioresponses() as m:
# Mock journal file not found
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
status=404,
payload={"error": "File not found"}
)
# Simulate error scenario
user_input = "整理今天的日记"
response = await agent.process_message(user_input)
assert response is not None
assert "找不到" in response or "not found" in response.lower() or "错误" in response
# Follow up with help request
help_response = await agent.process_message("我应该怎么办?")
assert help_response is not None
assert "建议" in help_response or "suggest" in help_response.lower() or "帮助" in help_response
+458
View File
@@ -0,0 +1,458 @@
"""
Integration tests for Obsidian Skills.
Tests Skills with mocked API responses to verify end-to-end functionality.
"""
import pytest
import json
from unittest.mock import AsyncMock, patch, Mock
from aioresponses import aioresponses
from agent_core import CommandContext, SkillResult
from skills.obsidian_skill import (
ObsidianReadSkill, ObsidianWriteSkill, ObsidianAppendSkill,
ObsidianListFilesSkill
)
class TestObsidianReadSkill:
"""Integration tests for ObsidianReadSkill"""
@pytest.fixture
def skill(self):
"""Create ObsidianReadSkill instance"""
return ObsidianReadSkill()
@pytest.fixture
def context(self, sample_config):
"""Create command context with Obsidian configuration"""
return CommandContext(
command_name="test_read",
args={"file_path": "Daily/2024-01-15.md"},
config=sample_config
)
@pytest.mark.asyncio
async def test_read_note_success(self, skill, context):
"""Test successful note reading"""
mock_response = {
"content": "# 2024-01-15 Daily Journal\n\nTest content",
"stat": {
"ctime": 1642204800000,
"mtime": 1642204800000,
"size": 45
}
}
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload=mock_response,
status=200
)
result = await skill.execute(context)
assert result.success is True
assert result.data["content"] == mock_response["content"]
assert result.data["file_path"] == "Daily/2024-01-15.md"
assert "stat" in result.data
@pytest.mark.asyncio
async def test_read_note_not_found(self, skill, context):
"""Test reading non-existent note"""
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
status=404,
payload={"error": "File not found"}
)
result = await skill.execute(context)
assert result.success is False
assert "not found" in result.error.lower()
@pytest.mark.asyncio
async def test_read_note_api_error(self, skill, context):
"""Test API connection error"""
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
exception=Exception("Connection failed")
)
result = await skill.execute(context)
assert result.success is False
assert "connection" in result.error.lower() or "api" in result.error.lower()
@pytest.mark.asyncio
async def test_read_note_invalid_response(self, skill, context):
"""Test handling of invalid API response"""
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload="invalid json response",
status=200
)
result = await skill.execute(context)
assert result.success is False
assert "response" in result.error.lower()
class TestObsidianWriteSkill:
"""Integration tests for ObsidianWriteSkill"""
@pytest.fixture
def skill(self):
"""Create ObsidianWriteSkill instance"""
return ObsidianWriteSkill()
@pytest.fixture
def context(self, sample_config):
"""Create command context with write parameters"""
return CommandContext(
command_name="test_write",
args={
"file_path": "Knowledge/Experiences/test-experience.md",
"content": "# Test Experience\n\nThis is a test experience note."
},
config=sample_config
)
@pytest.mark.asyncio
async def test_write_note_success(self, skill, context):
"""Test successful note writing"""
mock_response = {
"path": "Knowledge/Experiences/test-experience.md",
"stat": {
"ctime": 1642204800000,
"mtime": 1642204800000,
"size": 45
}
}
with aioresponses() as m:
m.put(
"https://localhost:27123/vault/Knowledge/Experiences/test-experience.md",
payload=mock_response,
status=200
)
result = await skill.execute(context)
assert result.success is True
assert result.data["file_path"] == "Knowledge/Experiences/test-experience.md"
assert "created" in result.message.lower() or "written" in result.message.lower()
@pytest.mark.asyncio
async def test_write_note_permission_error(self, skill, context):
"""Test write permission error"""
with aioresponses() as m:
m.put(
"https://localhost:27123/vault/Knowledge/Experiences/test-experience.md",
status=403,
payload={"error": "Permission denied"}
)
result = await skill.execute(context)
assert result.success is False
assert "permission" in result.error.lower()
@pytest.mark.asyncio
async def test_write_note_missing_content(self, skill, sample_config):
"""Test writing note without content"""
context = CommandContext(
command_name="test_write",
args={"file_path": "test.md"}, # Missing content
config=sample_config
)
result = await skill.execute(context)
assert result.success is False
assert "content" in result.error.lower()
class TestObsidianAppendSkill:
"""Integration tests for ObsidianAppendSkill"""
@pytest.fixture
def skill(self):
"""Create ObsidianAppendSkill instance"""
return ObsidianAppendSkill()
@pytest.fixture
def context(self, sample_config):
"""Create command context with append parameters"""
return CommandContext(
command_name="test_append",
args={
"file_path": "Daily/2024-01-15.md",
"content": "\n\n## Additional Notes\n\nAppended content."
},
config=sample_config
)
@pytest.mark.asyncio
async def test_append_to_existing_note_success(self, skill, context):
"""Test successful content appending to existing note"""
# Mock reading existing content
existing_content = "# 2024-01-15 Daily Journal\n\nExisting content"
read_response = {
"content": existing_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 45}
}
# Mock writing updated content
write_response = {
"path": "Daily/2024-01-15.md",
"stat": {"ctime": 1642204800000, "mtime": 1642204900000, "size": 90}
}
with aioresponses() as m:
# Mock GET request for reading existing content
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload=read_response,
status=200
)
# Mock PUT request for writing updated content
m.put(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload=write_response,
status=200
)
result = await skill.execute(context)
assert result.success is True
assert result.data["file_path"] == "Daily/2024-01-15.md"
assert "appended" in result.message.lower()
@pytest.mark.asyncio
async def test_append_to_nonexistent_note(self, skill, context):
"""Test appending to non-existent note (should create new note)"""
write_response = {
"path": "Daily/2024-01-15.md",
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 45}
}
with aioresponses() as m:
# Mock GET request returning 404 (file doesn't exist)
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
status=404
)
# Mock PUT request for creating new file
m.put(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload=write_response,
status=200
)
result = await skill.execute(context)
assert result.success is True
assert "created" in result.message.lower()
class TestObsidianListFilesSkill:
"""Integration tests for ObsidianListFilesSkill"""
@pytest.fixture
def skill(self):
"""Create ObsidianListFilesSkill instance"""
return ObsidianListFilesSkill()
@pytest.fixture
def context(self, sample_config):
"""Create command context with list parameters"""
return CommandContext(
command_name="test_list",
args={"folder_path": "Daily"},
config=sample_config
)
@pytest.mark.asyncio
async def test_list_files_success(self, skill, context):
"""Test successful file listing"""
mock_response = {
"files": [
{
"path": "Daily/2024-01-15.md",
"name": "2024-01-15.md",
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 45}
},
{
"path": "Daily/2024-01-14.md",
"name": "2024-01-14.md",
"stat": {"ctime": 1642118400000, "mtime": 1642118400000, "size": 38}
}
]
}
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/",
payload=mock_response,
status=200
)
result = await skill.execute(context)
assert result.success is True
assert len(result.data["files"]) == 2
assert result.data["folder_path"] == "Daily"
assert any(file["name"] == "2024-01-15.md" for file in result.data["files"])
@pytest.mark.asyncio
async def test_list_files_empty_folder(self, skill, context):
"""Test listing files in empty folder"""
mock_response = {"files": []}
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/",
payload=mock_response,
status=200
)
result = await skill.execute(context)
assert result.success is True
assert len(result.data["files"]) == 0
@pytest.mark.asyncio
async def test_list_files_folder_not_found(self, skill, context):
"""Test listing files in non-existent folder"""
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/",
status=404,
payload={"error": "Folder not found"}
)
result = await skill.execute(context)
assert result.success is False
assert "not found" in result.error.lower()
class TestObsidianSkillsIntegration:
"""Integration tests combining multiple Obsidian skills"""
@pytest.mark.asyncio
async def test_read_write_workflow(self, sample_config):
"""Test complete read-modify-write workflow"""
read_skill = ObsidianReadSkill()
write_skill = ObsidianWriteSkill()
# Read existing content
read_context = CommandContext(
command_name="read",
args={"file_path": "Daily/2024-01-15.md"},
config=sample_config
)
# Write modified content
write_context = CommandContext(
command_name="write",
args={
"file_path": "Knowledge/Processed/2024-01-15-summary.md",
"content": "# Summary\n\nProcessed content from daily journal."
},
config=sample_config
)
with aioresponses() as m:
# Mock read response
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": "# 2024-01-15 Daily Journal\n\nOriginal content",
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 45}
},
status=200
)
# Mock write response
m.put(
"https://localhost:27123/vault/Knowledge/Processed/2024-01-15-summary.md",
payload={
"path": "Knowledge/Processed/2024-01-15-summary.md",
"stat": {"ctime": 1642204900000, "mtime": 1642204900000, "size": 60}
},
status=200
)
# Execute read
read_result = await read_skill.execute(read_context)
assert read_result.success is True
# Execute write (in real scenario, content would be processed)
write_result = await write_skill.execute(write_context)
assert write_result.success is True
# Verify workflow completed successfully
assert read_result.data["content"] is not None
assert write_result.data["file_path"] == "Knowledge/Processed/2024-01-15-summary.md"
@pytest.mark.asyncio
async def test_list_and_read_multiple_files(self, sample_config):
"""Test listing files and reading multiple files"""
list_skill = ObsidianListFilesSkill()
read_skill = ObsidianReadSkill()
list_context = CommandContext(
command_name="list",
args={"folder_path": "Daily"},
config=sample_config
)
with aioresponses() as m:
# Mock list response
m.get(
"https://localhost:27123/vault/Daily/",
payload={
"files": [
{"path": "Daily/2024-01-15.md", "name": "2024-01-15.md"},
{"path": "Daily/2024-01-14.md", "name": "2024-01-14.md"}
]
},
status=200
)
# Mock read responses for each file
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={"content": "Content 1", "stat": {}},
status=200
)
m.get(
"https://localhost:27123/vault/Daily/2024-01-14.md",
payload={"content": "Content 2", "stat": {}},
status=200
)
# List files
list_result = await list_skill.execute(list_context)
assert list_result.success is True
assert len(list_result.data["files"]) == 2
# Read each file
for file_info in list_result.data["files"]:
read_context = CommandContext(
command_name="read",
args={"file_path": file_info["path"]},
config=sample_config
)
read_result = await read_skill.execute(read_context)
assert read_result.success is True
assert read_result.data["content"] is not None
+488
View File
@@ -0,0 +1,488 @@
"""
Integration tests for OrganizeCommand.
Tests Commands with full skill chains to verify end-to-end functionality.
"""
import pytest
import json
from unittest.mock import AsyncMock, patch, Mock
from aioresponses import aioresponses
from agent_core import CommandContext, SkillResult, Agent
from commands.organize_command import OrganizeCommand
class TestOrganizeCommand:
"""Integration tests for OrganizeCommand"""
@pytest.fixture
def command(self):
"""Create OrganizeCommand instance"""
return OrganizeCommand()
@pytest.fixture
def context(self, sample_config):
"""Create command context for organize command"""
return CommandContext(
command_name="organize",
args={
"date": "2024-01-15",
"vault_path": sample_config["obsidian"]["vault_path"],
"daily_folder": "Daily"
},
config=sample_config
)
@pytest.fixture
def sample_journal_content(self):
"""Sample journal content for testing"""
return """# 2024-01-15 Daily Journal
## 今天的经历
- 完成了项目的重要里程碑,团队协作非常顺利
- 与客户进行了产品演示,获得了积极反馈
- 参加了技术分享会议,学到了新的架构模式
## 学到的东西
- 学会了新的Python异步编程技巧,提升了代码效率
- 理解了微服务架构的最佳实践
- 掌握了更好的错误处理和日志记录模式
## 遇到的问题
- API调用偶尔超时,影响用户体验
- 配置文件格式需要改进,当前格式不够灵活
- 团队沟通中存在信息不对称问题
## 今天的成就
- 成功部署了新版本到生产环境
- 解决了困扰团队一周的性能问题
- 获得了客户的正面评价
## 明天的计划
- 优化API调用的重试机制
- 重构配置管理模块
- 组织团队同步会议
"""
@pytest.mark.asyncio
async def test_organize_command_full_workflow_success(self, command, context, sample_journal_content):
"""Test complete organize command workflow with all skills"""
# Mock Claude API responses
analyze_response = {
"experiences": [
{
"title": "项目里程碑完成",
"description": "成功完成了项目的重要里程碑,团队协作非常顺利",
"category": "项目管理",
"importance": "high"
},
{
"title": "客户产品演示",
"description": "与客户进行了产品演示,获得了积极反馈",
"category": "客户关系",
"importance": "high"
}
],
"lessons": [
{
"title": "Python异步编程技巧",
"description": "学会了新的Python异步编程技巧,提升了代码效率",
"category": "技术学习",
"application": "可以应用到当前项目的API调用优化中"
},
{
"title": "微服务架构最佳实践",
"description": "理解了微服务架构的最佳实践",
"category": "架构设计",
"application": "用于指导下一个项目的架构设计"
}
],
"problems": [
{
"title": "API调用超时",
"description": "API调用偶尔超时,影响用户体验",
"severity": "medium",
"suggested_solution": "实现重试机制和超时处理"
}
],
"achievements": [
{
"title": "生产环境部署",
"description": "成功部署了新版本到生产环境",
"impact": "提升了系统稳定性和性能"
}
]
}
# Mock transformation responses
experience_note = """# 项目里程碑完成
## 经验描述
成功完成了项目的重要里程碑,团队协作非常顺利。这次经历展现了良好的项目管理能力和团队协作精神。
## 关键要点
- 项目管理技能得到提升
- 团队协作效率显著改善
- 里程碑按时完成
## 应用场景
这个经验可以应用到未来的项目管理中,特别是在设定和跟踪项目里程碑方面。
## 相关标签
#项目管理 #里程碑 #团队协作
---
*创建时间: 2024-01-15*
*来源: [[Daily/2024-01-15]]*
"""
lesson_note = """# Python异步编程技巧
## 学习内容
学会了新的Python异步编程技巧,提升了代码效率。
## 关键概念
- 异步编程模式
- 性能优化技巧
- 代码效率提升
## 实际应用
可以应用到当前项目的API调用优化中,提升系统响应速度。
## 相关标签
#技术学习 #Python #异步编程
---
*创建时间: 2024-01-15*
*来源: [[Daily/2024-01-15]]*
"""
with aioresponses() as m:
# Mock Obsidian API calls
# 1. Read daily journal
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": sample_journal_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": len(sample_journal_content)}
},
status=200
)
# 2. Write experience note
m.put(
"https://localhost:27123/vault/Knowledge/Experiences/项目里程碑完成.md",
payload={
"path": "Knowledge/Experiences/项目里程碑完成.md",
"stat": {"ctime": 1642204900000, "mtime": 1642204900000, "size": len(experience_note)}
},
status=200
)
# 3. Write lesson note
m.put(
"https://localhost:27123/vault/Knowledge/Lessons/Python异步编程技巧.md",
payload={
"path": "Knowledge/Lessons/Python异步编程技巧.md",
"stat": {"ctime": 1642204900000, "mtime": 1642204900000, "size": len(lesson_note)}
},
status=200
)
# 4. Additional notes for other categories (problems, achievements)
m.put(
"https://localhost:27123/vault/Knowledge/Problems/API调用超时.md",
payload={"path": "Knowledge/Problems/API调用超时.md", "stat": {}},
status=200
)
m.put(
"https://localhost:27123/vault/Knowledge/Achievements/生产环境部署.md",
payload={"path": "Knowledge/Achievements/生产环境部署.md", "stat": {}},
status=200
)
# Mock Claude API
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock analyze response
mock_analyze_message = Mock()
mock_analyze_message.content = [Mock(text=json.dumps(analyze_response, ensure_ascii=False))]
# Mock transform responses (one for each item to be transformed)
mock_transform_responses = [
Mock(content=[Mock(text=experience_note)]),
Mock(content=[Mock(text=experience_note)]), # Second experience
Mock(content=[Mock(text=lesson_note)]),
Mock(content=[Mock(text=lesson_note)]), # Second lesson
Mock(content=[Mock(text="# API调用超时\n\n问题描述...")]), # Problem note
Mock(content=[Mock(text="# 生产环境部署\n\n成就描述...")]) # Achievement note
]
# Set up responses: first analyze, then multiple transforms
mock_client.messages.create.side_effect = [mock_analyze_message] + mock_transform_responses
# Execute the organize command
result = await command.execute(context)
# Verify overall success
assert result.success is True
assert "organized successfully" in result.message.lower() or "completed" in result.message.lower()
# Verify data structure
assert "summary" in result.data
assert "created_notes" in result.data
# Verify created notes
created_notes = result.data["created_notes"]
assert len(created_notes) > 0
# Should have created notes for experiences, lessons, problems, achievements
note_types = [note.get("type") for note in created_notes]
expected_types = ["experience", "lesson", "problem", "achievement"]
for expected_type in expected_types:
assert any(expected_type in note_type for note_type in note_types if note_type)
# Verify API calls were made
assert mock_client.messages.create.call_count >= 2 # At least analyze + some transforms
@pytest.mark.asyncio
async def test_organize_command_journal_not_found(self, command, context):
"""Test organize command when daily journal doesn't exist"""
with aioresponses() as m:
# Mock journal file not found
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
status=404,
payload={"error": "File not found"}
)
result = await command.execute(context)
assert result.success is False
assert "not found" in result.error.lower() or "missing" in result.error.lower()
@pytest.mark.asyncio
async def test_organize_command_claude_api_error(self, command, context, sample_journal_content):
"""Test organize command when Claude API fails"""
with aioresponses() as m:
# Mock successful journal read
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": sample_journal_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": len(sample_journal_content)}
},
status=200
)
# Mock Claude API error
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
mock_client.messages.create.side_effect = Exception("Claude API rate limit exceeded")
result = await command.execute(context)
assert result.success is False
assert "api" in result.error.lower() or "claude" in result.error.lower()
@pytest.mark.asyncio
async def test_organize_command_partial_success(self, command, context, sample_journal_content):
"""Test organize command with partial success (some notes created, some failed)"""
analyze_response = {
"experiences": [
{
"title": "Test Experience",
"description": "Test description",
"category": "Test",
"importance": "medium"
}
],
"lessons": [],
"problems": [],
"achievements": []
}
with aioresponses() as m:
# Mock successful journal read
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": sample_journal_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": len(sample_journal_content)}
},
status=200
)
# Mock successful experience note creation
m.put(
"https://localhost:27123/vault/Knowledge/Experiences/Test Experience.md",
payload={
"path": "Knowledge/Experiences/Test Experience.md",
"stat": {"ctime": 1642204900000, "mtime": 1642204900000, "size": 100}
},
status=200
)
# Mock Claude API
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock analyze response
mock_analyze_message = Mock()
mock_analyze_message.content = [Mock(text=json.dumps(analyze_response, ensure_ascii=False))]
# Mock transform response
mock_transform_message = Mock()
mock_transform_message.content = [Mock(text="# Test Experience\n\nTransformed content")]
mock_client.messages.create.side_effect = [mock_analyze_message, mock_transform_message]
result = await command.execute(context)
# Should succeed even with minimal content
assert result.success is True
assert len(result.data["created_notes"]) >= 1
@pytest.mark.asyncio
async def test_organize_command_empty_journal(self, command, context):
"""Test organize command with empty journal content"""
with aioresponses() as m:
# Mock journal with empty content
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": "",
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 0}
},
status=200
)
result = await command.execute(context)
assert result.success is False
assert "empty" in result.error.lower() or "content" in result.error.lower()
class TestOrganizeCommandWithAgent:
"""Integration tests for OrganizeCommand within Agent context"""
@pytest.fixture
def agent(self, sample_config):
"""Create Agent with OrganizeCommand registered"""
agent = Agent("test_agent", sample_config)
agent.register_command(OrganizeCommand())
return agent
@pytest.mark.asyncio
async def test_agent_execute_organize_command(self, agent, sample_journal_content):
"""Test executing organize command through Agent"""
with aioresponses() as m:
# Mock Obsidian API
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": sample_journal_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": len(sample_journal_content)}
},
status=200
)
# Mock note creation (simplified - just one note)
m.put(
"https://localhost:27123/vault/Knowledge/Experiences/Test.md",
payload={"path": "Knowledge/Experiences/Test.md", "stat": {}},
status=200
)
# Mock Claude API
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Minimal response for testing
analyze_response = {
"experiences": [{"title": "Test", "description": "Test", "category": "Test"}],
"lessons": [],
"problems": [],
"achievements": []
}
mock_analyze_message = Mock()
mock_analyze_message.content = [Mock(text=json.dumps(analyze_response, ensure_ascii=False))]
mock_transform_message = Mock()
mock_transform_message.content = [Mock(text="# Test\n\nTest content")]
mock_client.messages.create.side_effect = [mock_analyze_message, mock_transform_message]
# Execute command through agent
result = await agent.execute_command(
"organize",
args={"date": "2024-01-15"},
options={"verbose": True}
)
assert result.success is True
assert result.data is not None
@pytest.mark.asyncio
async def test_agent_execute_organize_by_alias(self, agent, sample_journal_content):
"""Test executing organize command by alias through Agent"""
with aioresponses() as m:
# Mock minimal successful workflow
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": sample_journal_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": len(sample_journal_content)}
},
status=200
)
# Mock Claude API with minimal response
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
analyze_response = {"experiences": [], "lessons": [], "problems": [], "achievements": []}
mock_message = Mock()
mock_message.content = [Mock(text=json.dumps(analyze_response))]
mock_client.messages.create.return_value = mock_message
# Execute by alias
result = await agent.execute_command("org", args={"date": "2024-01-15"})
assert result.success is True
@pytest.mark.asyncio
async def test_agent_command_info(self, agent):
"""Test getting command information through Agent"""
commands_info = agent.get_commands_info()
assert "organize" in commands_info["commands"]
organize_info = commands_info["commands"]["organize"]
assert organize_info["name"] == "organize"
assert organize_info["description"] == "分析和整理日记内容,提取经验和要点"
assert "org" in organize_info["aliases"]
assert "organize-journal" in organize_info["aliases"]
# Verify skills are registered
assert len(organize_info["skills"]) > 0
skill_names = list(organize_info["skills"].keys())
expected_skills = ["obsidian_read", "obsidian_write", "obsidian_append", "claude_analyze", "claude_transform"]
for expected_skill in expected_skills:
assert any(expected_skill in skill_name for skill_name in skill_names)
@@ -0,0 +1,512 @@
"""
Integration tests for real Claude API endpoints
Tests validation with default Anthropic API, proxy server configurations, and different model selections
Requirements: 1.3, 2.4
"""
import os
import pytest
import asyncio
import tempfile
import yaml
from pathlib import Path
from typing import Dict, Any, Optional
from unittest.mock import patch, Mock
# Import the modules we're testing
from config_validation import ClaudeAPIConfig
from claude_api_client import ClaudeAPIClient
from error_handling import APIError
class TestRealAPIEndpoints:
"""Test with real API endpoints - requires valid API key"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
# Check if we have a real API key for testing
self.api_key = os.getenv('ANTHROPIC_API_KEY')
self.has_real_api_key = (
self.api_key and
self.api_key.startswith('sk-ant-') and
len(self.api_key) > 50
)
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
@pytest.mark.skipif(
not os.getenv('ANTHROPIC_API_KEY'),
reason="Requires ANTHROPIC_API_KEY environment variable for real API testing"
)
@pytest.mark.asyncio
async def test_default_anthropic_api_validation(self):
"""Test validation with default Anthropic API"""
if not self.has_real_api_key:
pytest.skip("Requires valid ANTHROPIC_API_KEY for real API testing")
config = ClaudeAPIConfig(
api_key=self.api_key,
api_url="https://api.anthropic.com",
model="claude-3-5-sonnet-20241022"
)
client = ClaudeAPIClient(config)
# Test connection validation
is_valid = await client.validate_connection()
assert is_valid is True
# Test model availability
is_model_valid = await client.validate_model_availability()
assert is_model_valid is True
# Test comprehensive connectivity
results = await client.test_api_connectivity()
assert results['overall_status'] == 'success'
assert results['connection_test']['status'] == 'success'
assert results['model_test']['status'] == 'success'
assert results['authentication_test']['status'] == 'success'
@pytest.mark.skipif(
not os.getenv('ANTHROPIC_API_KEY'),
reason="Requires ANTHROPIC_API_KEY environment variable for real API testing"
)
@pytest.mark.asyncio
async def test_different_model_selections(self):
"""Test different Claude model selections work correctly"""
if not self.has_real_api_key:
pytest.skip("Requires valid ANTHROPIC_API_KEY for real API testing")
# Test different models that should be available
models_to_test = [
"claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307",
# Note: claude-3-opus may not be available in all regions/accounts
]
for model in models_to_test:
config = ClaudeAPIConfig(
api_key=self.api_key,
api_url="https://api.anthropic.com",
model=model
)
client = ClaudeAPIClient(config)
try:
# Test that the model is available
is_valid = await client.validate_model_availability()
assert is_valid is True, f"Model {model} should be available"
# Test a simple API call with the model
response = await client.create_message([
{"role": "user", "content": "Hello"}
])
assert response is not None
assert hasattr(response, 'content')
assert len(response.content) > 0
except APIError as e:
# Some models might not be available in all regions/accounts
if "model" in str(e).lower() and "not found" in str(e).lower():
pytest.skip(f"Model {model} not available in this account/region")
else:
raise
@pytest.mark.asyncio
async def test_invalid_api_key_handling(self):
"""Test handling of invalid API keys"""
config = ClaudeAPIConfig(
api_key="sk-ant-invalid-key-12345678901234567890123456789012345678901234567890",
api_url="https://api.anthropic.com",
model="claude-3-5-sonnet-20241022"
)
client = ClaudeAPIClient(config)
with pytest.raises(APIError, match="Invalid API key or unauthorized access"):
await client.validate_connection()
@pytest.mark.asyncio
async def test_invalid_api_url_handling(self):
"""Test handling of invalid API URLs"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="https://nonexistent-api.example.com",
model="claude-3-5-sonnet-20241022"
)
client = ClaudeAPIClient(config)
with pytest.raises(APIError, match="Connection error"):
await client.validate_connection()
@pytest.mark.asyncio
async def test_invalid_model_handling(self):
"""Test handling of invalid model names"""
if not self.has_real_api_key:
pytest.skip("Requires valid ANTHROPIC_API_KEY for real API testing")
config = ClaudeAPIConfig(
api_key=self.api_key,
api_url="https://api.anthropic.com",
model="claude-nonexistent-model"
)
client = ClaudeAPIClient(config)
with pytest.raises(APIError, match="Model.*not found"):
await client.validate_model_availability()
class TestProxyServerConfigurations:
"""Test proxy server configurations"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
@pytest.mark.asyncio
async def test_localhost_proxy_configuration(self):
"""Test configuration for localhost proxy servers"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="https://localhost:8080",
model="claude-3-5-sonnet-20241022"
)
client = ClaudeAPIClient(config)
# Test that SSL context is configured for localhost
async with client.create_http_session() as session:
# Should not raise SSL errors for localhost
assert session is not None
# Verify SSL context is configured for localhost
connector = session.connector
assert connector.ssl is not None
# For localhost, SSL verification should be disabled
assert not connector.ssl.check_hostname
@pytest.mark.asyncio
async def test_custom_proxy_url_configuration(self):
"""Test configuration for custom proxy URLs"""
proxy_urls = [
"https://proxy.example.com:8080",
"https://claude-proxy.internal:443",
"http://localhost:3128"
]
for proxy_url in proxy_urls:
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url=proxy_url,
model="claude-3-5-sonnet-20241022"
)
client = ClaudeAPIClient(config)
# Test client initialization
assert client.base_url == proxy_url
# Test client info
info = client.get_client_info()
assert info['api_url'] == proxy_url
assert info['is_custom_endpoint'] is True
def test_proxy_configuration_validation(self):
"""Test validation of proxy server configurations"""
# Valid proxy configurations
valid_configs = [
{
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'https://proxy.example.com:8080',
'model': 'claude-3-5-sonnet-20241022'
},
{
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'http://localhost:3128',
'model': 'claude-3-5-sonnet-20241022'
}
]
for config_data in valid_configs:
config = ClaudeAPIConfig(**config_data)
assert config.api_url == config_data['api_url']
# Invalid proxy configurations
invalid_configs = [
{
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'ftp://proxy.example.com:8080', # Invalid protocol
'model': 'claude-3-5-sonnet-20241022'
},
{
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'not-a-url', # Invalid URL format
'model': 'claude-3-5-sonnet-20241022'
}
]
for config_data in invalid_configs:
with pytest.raises(ValueError):
ClaudeAPIConfig(**config_data)
class TestConfigurationValidationClass:
"""Test configuration validation class methods"""
@pytest.mark.skipif(
not os.getenv('ANTHROPIC_API_KEY'),
reason="Requires ANTHROPIC_API_KEY environment variable for real API testing"
)
@pytest.mark.asyncio
async def test_validate_configuration_quick_test(self):
"""Test quick configuration validation"""
api_key = os.getenv('ANTHROPIC_API_KEY')
if not api_key or not api_key.startswith('sk-ant-'):
pytest.skip("Requires valid ANTHROPIC_API_KEY for real API testing")
config = ClaudeAPIConfig(
api_key=api_key,
api_url="https://api.anthropic.com",
model="claude-3-5-sonnet-20241022"
)
results = await ClaudeAPIClient.validate_configuration(config, quick_test=True)
assert results['config_valid'] is True
assert results['connection_valid'] is True
assert results['model_valid'] is True
assert len(results['errors']) == 0
@pytest.mark.skipif(
not os.getenv('ANTHROPIC_API_KEY'),
reason="Requires ANTHROPIC_API_KEY environment variable for real API testing"
)
@pytest.mark.asyncio
async def test_validate_configuration_comprehensive_test(self):
"""Test comprehensive configuration validation"""
api_key = os.getenv('ANTHROPIC_API_KEY')
if not api_key or not api_key.startswith('sk-ant-'):
pytest.skip("Requires valid ANTHROPIC_API_KEY for real API testing")
config = ClaudeAPIConfig(
api_key=api_key,
api_url="https://api.anthropic.com",
model="claude-3-5-sonnet-20241022"
)
results = await ClaudeAPIClient.validate_configuration(config, quick_test=False)
assert results['config_valid'] is True
assert results['connection_valid'] is True
assert results['model_valid'] is True
assert len(results['errors']) == 0
# Should have detailed test results
assert 'detailed_tests' in results
detailed = results['detailed_tests']
assert detailed['overall_status'] == 'success'
assert detailed['connection_test']['status'] == 'success'
assert detailed['model_test']['status'] == 'success'
assert detailed['authentication_test']['status'] == 'success'
@pytest.mark.asyncio
async def test_validate_configuration_invalid_key(self):
"""Test configuration validation with invalid API key"""
config = ClaudeAPIConfig(
api_key="sk-ant-invalid-key-12345678901234567890123456789012345678901234567890",
api_url="https://api.anthropic.com",
model="claude-3-5-sonnet-20241022"
)
results = await ClaudeAPIClient.validate_configuration(config, quick_test=True)
assert results['config_valid'] is True # Config format is valid
assert results['connection_valid'] is False # But connection fails
assert len(results['errors']) > 0
assert any('Invalid API key' in error for error in results['errors'])
@pytest.mark.asyncio
async def test_validate_configuration_custom_endpoint(self):
"""Test configuration validation with custom endpoint"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="https://custom-claude-api.example.com",
model="claude-3-5-sonnet-20241022"
)
results = await ClaudeAPIClient.validate_configuration(config, quick_test=True)
assert results['config_valid'] is True
assert results['config_info']['is_custom_endpoint'] is True
assert results['config_info']['api_url'] == "https://custom-claude-api.example.com"
# Should have warning about custom endpoint
assert len(results['warnings']) > 0
assert any('custom API endpoint' in warning for warning in results['warnings'])
class TestEnvironmentVariableScenarios:
"""Test environment variable scenarios in real configurations"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
# Store original environment variables
self.original_env = {}
test_vars = ['TEST_CLAUDE_API_KEY', 'TEST_CLAUDE_API_URL', 'TEST_CLAUDE_MODEL']
for var in test_vars:
if var in os.environ:
self.original_env[var] = os.environ[var]
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
# Clean up test environment variables
test_vars = ['TEST_CLAUDE_API_KEY', 'TEST_CLAUDE_API_URL', 'TEST_CLAUDE_MODEL']
for var in test_vars:
if var in os.environ:
del os.environ[var]
# Restore original environment variables
for var, value in self.original_env.items():
os.environ[var] = value
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_environment_variable_configuration_loading(self):
"""Test loading configuration with environment variables"""
# Set up environment variables
os.environ['TEST_CLAUDE_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
os.environ['TEST_CLAUDE_API_URL'] = 'https://custom-api.example.com'
os.environ['TEST_CLAUDE_MODEL'] = 'claude-3-haiku-20240307'
config_data = {
'claude': {
'api_key': '${TEST_CLAUDE_API_KEY}',
'api_url': '${TEST_CLAUDE_API_URL}',
'model': '${TEST_CLAUDE_MODEL}'
}
}
config_file = self.create_test_config_file(config_data)
# Load configuration through the configuration loader
from configuration_loader import ConfigurationLoader
loader = ConfigurationLoader()
loaded_config = loader.load_config(config_file)
# Validate the loaded configuration
claude_config = ClaudeAPIConfig(**loaded_config['claude'])
assert claude_config.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert claude_config.api_url == 'https://custom-api.example.com'
assert claude_config.model == 'claude-3-haiku-20240307'
def test_environment_variable_defaults_in_configuration(self):
"""Test environment variable defaults in configuration"""
config_data = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': '${CLAUDE_API_URL:-https://api.anthropic.com}',
'model': '${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}',
'max_tokens': '${CLAUDE_MAX_TOKENS:-4096}',
'temperature': '${CLAUDE_TEMPERATURE:-0.7}'
}
}
config_file = self.create_test_config_file(config_data)
# Load configuration through the configuration loader
from configuration_loader import ConfigurationLoader
loader = ConfigurationLoader()
loaded_config = loader.load_config(config_file)
# Should use defaults since environment variables are not set
claude_config = ClaudeAPIConfig(**loaded_config['claude'])
assert claude_config.api_url == 'https://api.anthropic.com'
assert claude_config.model == 'claude-3-5-sonnet-20241022'
assert claude_config.max_tokens == 4096
assert claude_config.temperature == 0.7
def test_mixed_environment_and_direct_configuration(self):
"""Test mixed configuration (some values from files, some from environment)"""
# Set only some environment variables
os.environ['TEST_CLAUDE_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
os.environ['TEST_CLAUDE_API_URL'] = 'https://proxy.example.com'
config_data = {
'claude': {
'api_key': '${TEST_CLAUDE_API_KEY}',
'api_url': '${TEST_CLAUDE_API_URL}',
'model': 'claude-3-5-sonnet-20241022', # Direct value
'max_tokens': 8192, # Direct value
'temperature': '${CLAUDE_TEMPERATURE:-0.5}' # Default value
}
}
config_file = self.create_test_config_file(config_data)
# Load configuration through the configuration loader
from configuration_loader import ConfigurationLoader
loader = ConfigurationLoader()
loaded_config = loader.load_config(config_file)
claude_config = ClaudeAPIConfig(**loaded_config['claude'])
# Environment variables should be expanded
assert claude_config.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert claude_config.api_url == 'https://proxy.example.com'
# Direct values should be preserved
assert claude_config.model == 'claude-3-5-sonnet-20241022'
assert claude_config.max_tokens == 8192
# Default should be used
assert claude_config.temperature == 0.5
def test_missing_required_environment_variable(self):
"""Test error handling for missing required environment variables"""
config_data = {
'claude': {
'api_key': '${MISSING_API_KEY}', # Required but not set
'api_url': 'https://api.anthropic.com',
'model': 'claude-3-5-sonnet-20241022'
}
}
config_file = self.create_test_config_file(config_data)
# Should raise error for missing required environment variable
from configuration_loader import ConfigurationLoader, EnvironmentVariableError
loader = ConfigurationLoader()
with pytest.raises(EnvironmentVariableError, match="Environment variable 'MISSING_API_KEY' is not set"):
loader.load_config(config_file)
if __name__ == "__main__":
# Run tests with pytest
pytest.main([__file__, "-v"])