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:
@@ -0,0 +1 @@
|
||||
# Unit tests package
|
||||
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
Unit tests for agent_core module.
|
||||
Tests Agent, Command, Skill, SkillResult, and related classes.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
from datetime import datetime
|
||||
|
||||
from agent_core import (
|
||||
Agent, Command, Skill, SkillResult, SkillChain,
|
||||
CommandContext, SkillType
|
||||
)
|
||||
|
||||
|
||||
class TestSkillResult:
|
||||
"""Test SkillResult class"""
|
||||
|
||||
def test_successful_result_creation(self):
|
||||
"""Test creating a successful SkillResult"""
|
||||
result = SkillResult(success=True, data={"key": "value"}, message="Success")
|
||||
|
||||
assert result.success is True
|
||||
assert result.data == {"key": "value"}
|
||||
assert result.message == "Success"
|
||||
assert result.error is None
|
||||
assert result.timestamp is not None
|
||||
|
||||
def test_failed_result_creation(self):
|
||||
"""Test creating a failed SkillResult"""
|
||||
result = SkillResult(success=False, error="Something went wrong", message="Failed")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "Something went wrong"
|
||||
assert result.message == "Failed"
|
||||
assert result.data is None
|
||||
|
||||
def test_failed_result_without_error_raises_exception(self):
|
||||
"""Test that failed result without error message raises ValueError"""
|
||||
with pytest.raises(ValueError, match="Failed results must include error message"):
|
||||
SkillResult(success=False)
|
||||
|
||||
def test_successful_result_with_error_raises_exception(self):
|
||||
"""Test that successful result with error message raises ValueError"""
|
||||
with pytest.raises(ValueError, match="Successful results should not include error message"):
|
||||
SkillResult(success=True, error="This shouldn't be here")
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test converting SkillResult to dictionary"""
|
||||
result = SkillResult(success=True, data={"test": "data"}, message="Test")
|
||||
result_dict = result.to_dict()
|
||||
|
||||
assert isinstance(result_dict, dict)
|
||||
assert result_dict["success"] is True
|
||||
assert result_dict["data"] == {"test": "data"}
|
||||
assert result_dict["message"] == "Test"
|
||||
assert "timestamp" in result_dict
|
||||
|
||||
def test_to_json(self):
|
||||
"""Test converting SkillResult to JSON string"""
|
||||
result = SkillResult(success=True, message="Test")
|
||||
json_str = result.to_json()
|
||||
|
||||
assert isinstance(json_str, str)
|
||||
assert '"success": true' in json_str
|
||||
assert '"message": "Test"' in json_str
|
||||
|
||||
|
||||
class TestCommandContext:
|
||||
"""Test CommandContext class"""
|
||||
|
||||
def test_context_creation(self):
|
||||
"""Test creating CommandContext"""
|
||||
context = CommandContext(
|
||||
command_name="test_command",
|
||||
args={"arg1": "value1"},
|
||||
options={"option1": True},
|
||||
config={"config_key": "config_value"}
|
||||
)
|
||||
|
||||
assert context.command_name == "test_command"
|
||||
assert context.args == {"arg1": "value1"}
|
||||
assert context.options == {"option1": True}
|
||||
assert context.config == {"config_key": "config_value"}
|
||||
assert isinstance(context.metadata, dict)
|
||||
|
||||
def test_context_defaults(self):
|
||||
"""Test CommandContext with default values"""
|
||||
context = CommandContext(command_name="test")
|
||||
|
||||
assert context.command_name == "test"
|
||||
assert context.args == {}
|
||||
assert context.options == {}
|
||||
assert context.config is None
|
||||
assert context.metadata == {}
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test converting CommandContext to dictionary"""
|
||||
context = CommandContext(command_name="test", args={"key": "value"})
|
||||
context_dict = context.to_dict()
|
||||
|
||||
assert isinstance(context_dict, dict)
|
||||
assert context_dict["command_name"] == "test"
|
||||
assert context_dict["args"] == {"key": "value"}
|
||||
|
||||
|
||||
class MockSkill(Skill):
|
||||
"""Mock Skill implementation for testing"""
|
||||
|
||||
def __init__(self, name: str, should_succeed: bool = True):
|
||||
super().__init__(name, SkillType.READ, f"Mock skill {name}")
|
||||
self.should_succeed = should_succeed
|
||||
self.execute_called = False
|
||||
self.execute_context = None
|
||||
self.execute_kwargs = None
|
||||
|
||||
async def execute(self, context: CommandContext, **kwargs) -> SkillResult:
|
||||
self.execute_called = True
|
||||
self.execute_context = context
|
||||
self.execute_kwargs = kwargs
|
||||
|
||||
if self.should_succeed:
|
||||
return SkillResult(success=True, data={"skill": self.name}, message=f"{self.name} executed")
|
||||
else:
|
||||
return SkillResult(success=False, error=f"{self.name} failed", message="Execution failed")
|
||||
|
||||
|
||||
class TestSkill:
|
||||
"""Test Skill base class"""
|
||||
|
||||
def test_skill_creation(self):
|
||||
"""Test creating a Skill"""
|
||||
skill = MockSkill("test_skill")
|
||||
|
||||
assert skill.name == "test_skill"
|
||||
assert skill.skill_type == SkillType.READ
|
||||
assert skill.description == "Mock skill test_skill"
|
||||
assert skill.logger is not None
|
||||
|
||||
def test_get_info(self):
|
||||
"""Test getting skill information"""
|
||||
skill = MockSkill("test_skill")
|
||||
info = skill.get_info()
|
||||
|
||||
assert info["name"] == "test_skill"
|
||||
assert info["type"] == "read"
|
||||
assert info["description"] == "Mock skill test_skill"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_execute_success(self):
|
||||
"""Test successful skill execution"""
|
||||
skill = MockSkill("test_skill", should_succeed=True)
|
||||
context = CommandContext(command_name="test")
|
||||
|
||||
result = await skill.execute(context, param1="value1")
|
||||
|
||||
assert skill.execute_called is True
|
||||
assert skill.execute_context == context
|
||||
assert skill.execute_kwargs == {"param1": "value1"}
|
||||
assert result.success is True
|
||||
assert result.data == {"skill": "test_skill"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_execute_failure(self):
|
||||
"""Test failed skill execution"""
|
||||
skill = MockSkill("test_skill", should_succeed=False)
|
||||
context = CommandContext(command_name="test")
|
||||
|
||||
result = await skill.execute(context)
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "test_skill failed"
|
||||
|
||||
|
||||
class TestSkillChain:
|
||||
"""Test SkillChain class"""
|
||||
|
||||
def test_skill_chain_creation(self):
|
||||
"""Test creating a SkillChain"""
|
||||
chain = SkillChain("test_chain", "Test chain description")
|
||||
|
||||
assert chain.name == "test_chain"
|
||||
assert chain.description == "Test chain description"
|
||||
assert chain.skills == []
|
||||
assert chain.logger is not None
|
||||
|
||||
def test_add_skill(self):
|
||||
"""Test adding skills to chain"""
|
||||
chain = SkillChain("test_chain")
|
||||
skill1 = MockSkill("skill1")
|
||||
skill2 = MockSkill("skill2")
|
||||
|
||||
result = chain.add_skill(skill1, {"param1": "value1"})
|
||||
chain.add_skill(skill2)
|
||||
|
||||
assert result == chain # Test fluent interface
|
||||
assert len(chain.skills) == 2
|
||||
assert chain.skills[0] == (skill1, {"param1": "value1"})
|
||||
assert chain.skills[1] == (skill2, {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_chain_execute_success(self):
|
||||
"""Test successful skill chain execution"""
|
||||
chain = SkillChain("test_chain")
|
||||
skill1 = MockSkill("skill1", should_succeed=True)
|
||||
skill2 = MockSkill("skill2", should_succeed=True)
|
||||
|
||||
chain.add_skill(skill1).add_skill(skill2)
|
||||
context = CommandContext(command_name="test")
|
||||
|
||||
result = await chain.execute(context)
|
||||
|
||||
assert result.success is True
|
||||
assert skill1.execute_called is True
|
||||
assert skill2.execute_called is True
|
||||
# Check that skill1 result was passed to context metadata
|
||||
assert "skill1_result" in context.metadata
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_chain_execute_failure(self):
|
||||
"""Test skill chain execution with failure"""
|
||||
chain = SkillChain("test_chain")
|
||||
skill1 = MockSkill("skill1", should_succeed=True)
|
||||
skill2 = MockSkill("skill2", should_succeed=False)
|
||||
skill3 = MockSkill("skill3", should_succeed=True)
|
||||
|
||||
chain.add_skill(skill1).add_skill(skill2).add_skill(skill3)
|
||||
context = CommandContext(command_name="test")
|
||||
|
||||
result = await chain.execute(context)
|
||||
|
||||
assert result.success is False
|
||||
assert skill1.execute_called is True
|
||||
assert skill2.execute_called is True
|
||||
assert skill3.execute_called is False # Should not execute after failure
|
||||
|
||||
def test_get_info(self):
|
||||
"""Test getting skill chain information"""
|
||||
chain = SkillChain("test_chain", "Test description")
|
||||
skill1 = MockSkill("skill1")
|
||||
skill2 = MockSkill("skill2")
|
||||
|
||||
chain.add_skill(skill1).add_skill(skill2)
|
||||
info = chain.get_info()
|
||||
|
||||
assert info["name"] == "test_chain"
|
||||
assert info["description"] == "Test description"
|
||||
assert len(info["skills"]) == 2
|
||||
assert info["skills"][0]["name"] == "skill1"
|
||||
assert info["skills"][1]["name"] == "skill2"
|
||||
|
||||
|
||||
class MockCommand(Command):
|
||||
"""Mock Command implementation for testing"""
|
||||
|
||||
def __init__(self, name: str, should_succeed: bool = True):
|
||||
super().__init__(name, f"Mock command {name}", ["mock_alias"])
|
||||
self.should_succeed = should_succeed
|
||||
self.execute_called = False
|
||||
self.execute_context = None
|
||||
|
||||
async def execute(self, context: CommandContext) -> SkillResult:
|
||||
self.execute_called = True
|
||||
self.execute_context = context
|
||||
|
||||
if self.should_succeed:
|
||||
return SkillResult(success=True, data={"command": self.name}, message=f"{self.name} executed")
|
||||
else:
|
||||
return SkillResult(success=False, error=f"{self.name} failed", message="Command failed")
|
||||
|
||||
|
||||
class TestCommand:
|
||||
"""Test Command base class"""
|
||||
|
||||
def test_command_creation(self):
|
||||
"""Test creating a Command"""
|
||||
command = MockCommand("test_command")
|
||||
|
||||
assert command.name == "test_command"
|
||||
assert command.description == "Mock command test_command"
|
||||
assert command.aliases == ["mock_alias"]
|
||||
assert command.skills == {}
|
||||
assert command.skill_chains == {}
|
||||
assert command.logger is not None
|
||||
|
||||
def test_register_skill(self):
|
||||
"""Test registering skills with command"""
|
||||
command = MockCommand("test_command")
|
||||
skill = MockSkill("test_skill")
|
||||
|
||||
result = command.register_skill(skill)
|
||||
|
||||
assert result == command # Test fluent interface
|
||||
assert command.skills["test_skill"] == skill
|
||||
|
||||
def test_register_skill_chain(self):
|
||||
"""Test registering skill chains with command"""
|
||||
command = MockCommand("test_command")
|
||||
chain = SkillChain("test_chain")
|
||||
|
||||
result = command.register_skill_chain(chain)
|
||||
|
||||
assert result == command # Test fluent interface
|
||||
assert command.skill_chains["test_chain"] == chain
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_execute_success(self):
|
||||
"""Test successful command execution"""
|
||||
command = MockCommand("test_command", should_succeed=True)
|
||||
context = CommandContext(command_name="test_command")
|
||||
|
||||
result = await command.execute(context)
|
||||
|
||||
assert command.execute_called is True
|
||||
assert command.execute_context == context
|
||||
assert result.success is True
|
||||
assert result.data == {"command": "test_command"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_execute_failure(self):
|
||||
"""Test failed command execution"""
|
||||
command = MockCommand("test_command", should_succeed=False)
|
||||
context = CommandContext(command_name="test_command")
|
||||
|
||||
result = await command.execute(context)
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "test_command failed"
|
||||
|
||||
def test_get_info(self):
|
||||
"""Test getting command information"""
|
||||
command = MockCommand("test_command")
|
||||
skill = MockSkill("test_skill")
|
||||
chain = SkillChain("test_chain")
|
||||
|
||||
command.register_skill(skill).register_skill_chain(chain)
|
||||
info = command.get_info()
|
||||
|
||||
assert info["name"] == "test_command"
|
||||
assert info["description"] == "Mock command test_command"
|
||||
assert info["aliases"] == ["mock_alias"]
|
||||
assert "test_skill" in info["skills"]
|
||||
assert "test_chain" in info["skill_chains"]
|
||||
|
||||
|
||||
class TestAgent:
|
||||
"""Test Agent class"""
|
||||
|
||||
def test_agent_creation(self):
|
||||
"""Test creating an Agent"""
|
||||
config = {"log_level": "DEBUG", "test_key": "test_value"}
|
||||
agent = Agent("test_agent", config)
|
||||
|
||||
assert agent.name == "test_agent"
|
||||
assert agent.config == config
|
||||
assert agent.commands == {}
|
||||
assert agent.command_aliases == {}
|
||||
assert agent.logger is not None
|
||||
|
||||
def test_agent_creation_without_config(self):
|
||||
"""Test creating an Agent without config"""
|
||||
agent = Agent("test_agent")
|
||||
|
||||
assert agent.name == "test_agent"
|
||||
assert agent.config == {}
|
||||
|
||||
def test_register_command(self):
|
||||
"""Test registering commands with agent"""
|
||||
agent = Agent("test_agent")
|
||||
command = MockCommand("test_command")
|
||||
|
||||
result = agent.register_command(command)
|
||||
|
||||
assert result == agent # Test fluent interface
|
||||
assert agent.commands["test_command"] == command
|
||||
assert agent.command_aliases["mock_alias"] == "test_command"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_success(self):
|
||||
"""Test successful command execution through agent"""
|
||||
agent = Agent("test_agent")
|
||||
command = MockCommand("test_command", should_succeed=True)
|
||||
agent.register_command(command)
|
||||
|
||||
result = await agent.execute_command("test_command", {"arg1": "value1"}, {"opt1": True})
|
||||
|
||||
assert result.success is True
|
||||
assert command.execute_called is True
|
||||
assert command.execute_context.command_name == "test_command"
|
||||
assert command.execute_context.args == {"arg1": "value1"}
|
||||
assert command.execute_context.options == {"opt1": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_by_alias(self):
|
||||
"""Test executing command by alias"""
|
||||
agent = Agent("test_agent")
|
||||
command = MockCommand("test_command")
|
||||
agent.register_command(command)
|
||||
|
||||
result = await agent.execute_command("mock_alias")
|
||||
|
||||
assert result.success is True
|
||||
assert command.execute_called is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_unknown_command(self):
|
||||
"""Test executing unknown command"""
|
||||
agent = Agent("test_agent")
|
||||
|
||||
result = await agent.execute_command("unknown_command")
|
||||
|
||||
assert result.success is False
|
||||
assert "未知命令: unknown_command" in result.error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_with_exception(self):
|
||||
"""Test command execution with exception"""
|
||||
agent = Agent("test_agent")
|
||||
command = MockCommand("test_command")
|
||||
|
||||
# Mock the execute method to raise an exception
|
||||
async def mock_execute_with_exception(context):
|
||||
raise RuntimeError("Test exception")
|
||||
|
||||
command.execute = mock_execute_with_exception
|
||||
agent.register_command(command)
|
||||
|
||||
result = await agent.execute_command("test_command")
|
||||
|
||||
assert result.success is False
|
||||
assert "Test exception" in result.error
|
||||
|
||||
def test_get_commands_info(self):
|
||||
"""Test getting all commands information"""
|
||||
agent = Agent("test_agent")
|
||||
command1 = MockCommand("command1")
|
||||
command2 = MockCommand("command2")
|
||||
|
||||
agent.register_command(command1).register_command(command2)
|
||||
info = agent.get_commands_info()
|
||||
|
||||
assert info["agent_name"] == "test_agent"
|
||||
assert "command1" in info["commands"]
|
||||
assert "command2" in info["commands"]
|
||||
assert info["aliases"]["mock_alias"] == "command2" # Last registered wins
|
||||
|
||||
def test_list_commands(self):
|
||||
"""Test listing all available commands"""
|
||||
agent = Agent("test_agent")
|
||||
command1 = MockCommand("command1")
|
||||
command2 = MockCommand("command2")
|
||||
|
||||
agent.register_command(command1).register_command(command2)
|
||||
commands = agent.list_commands()
|
||||
|
||||
assert "command1" in commands
|
||||
assert "command2" in commands
|
||||
assert len(commands) == 2
|
||||
@@ -0,0 +1,710 @@
|
||||
"""
|
||||
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'
|
||||
@@ -0,0 +1,526 @@
|
||||
"""
|
||||
Unit tests for error_handling module.
|
||||
Tests custom exceptions, ErrorHandler, and error sanitization.
|
||||
"""
|
||||
import pytest
|
||||
import logging
|
||||
from unittest.mock import Mock, patch
|
||||
from datetime import datetime
|
||||
|
||||
from error_handling import (
|
||||
JournalOrganizerError, ConfigurationError, APIError, ValidationError,
|
||||
FileSystemError, SecurityError, ErrorContext, ErrorHandler,
|
||||
get_error_handler, set_error_handler, audit_error_message_security
|
||||
)
|
||||
|
||||
|
||||
class TestJournalOrganizerError:
|
||||
"""Test base JournalOrganizerError class"""
|
||||
|
||||
def test_basic_error_creation(self):
|
||||
"""Test creating basic error"""
|
||||
error = JournalOrganizerError("Test error message")
|
||||
|
||||
assert str(error) == "Test error message"
|
||||
assert error.message == "Test error message"
|
||||
assert error.context == {}
|
||||
assert error.cause is None
|
||||
assert error.timestamp is not None
|
||||
|
||||
def test_error_with_context(self):
|
||||
"""Test creating error with context"""
|
||||
context = {"key": "value", "number": 42}
|
||||
error = JournalOrganizerError("Test error", context=context)
|
||||
|
||||
assert error.context == context
|
||||
|
||||
def test_error_with_cause(self):
|
||||
"""Test creating error with cause"""
|
||||
original_error = ValueError("Original error")
|
||||
error = JournalOrganizerError("Wrapped error", cause=original_error)
|
||||
|
||||
assert error.cause == original_error
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test converting error to dictionary"""
|
||||
context = {"test_key": "test_value"}
|
||||
original_error = RuntimeError("Original")
|
||||
error = JournalOrganizerError("Test error", context=context, cause=original_error)
|
||||
|
||||
error_dict = error.to_dict()
|
||||
|
||||
assert error_dict["error_type"] == "JournalOrganizerError"
|
||||
assert error_dict["message"] == "Test error"
|
||||
assert error_dict["context"] == context
|
||||
assert error_dict["cause"] == "Original"
|
||||
assert "timestamp" in error_dict
|
||||
|
||||
|
||||
class TestConfigurationError:
|
||||
"""Test ConfigurationError class"""
|
||||
|
||||
def test_basic_configuration_error(self):
|
||||
"""Test basic configuration error"""
|
||||
error = ConfigurationError("Config error", config_key="api_key")
|
||||
|
||||
assert error.message == "Config error"
|
||||
assert error.context["config_key"] == "api_key"
|
||||
|
||||
def test_configuration_error_with_sensitive_value(self):
|
||||
"""Test configuration error with sensitive value redaction"""
|
||||
error = ConfigurationError(
|
||||
"Invalid API key",
|
||||
config_key="api_key",
|
||||
config_value="sk-secret-key-123"
|
||||
)
|
||||
|
||||
assert error.context["config_key"] == "api_key"
|
||||
assert error.context["config_value"] == "[REDACTED]"
|
||||
|
||||
def test_configuration_error_with_non_sensitive_value(self):
|
||||
"""Test configuration error with non-sensitive value"""
|
||||
error = ConfigurationError(
|
||||
"Invalid timeout",
|
||||
config_key="timeout",
|
||||
config_value="30"
|
||||
)
|
||||
|
||||
assert error.context["config_key"] == "timeout"
|
||||
assert error.context["config_value"] == "30"
|
||||
|
||||
|
||||
class TestAPIError:
|
||||
"""Test APIError class"""
|
||||
|
||||
def test_basic_api_error(self):
|
||||
"""Test basic API error"""
|
||||
error = APIError("API call failed", api_name="claude", status_code=500)
|
||||
|
||||
assert error.message == "API call failed"
|
||||
assert error.context["api_name"] == "claude"
|
||||
assert error.context["status_code"] == 500
|
||||
|
||||
def test_api_error_with_long_response(self):
|
||||
"""Test API error with long response data truncation"""
|
||||
long_response = "x" * 1000
|
||||
error = APIError("API error", response_data=long_response)
|
||||
|
||||
assert len(error.context["response_data"]) <= 503 # 500 + "..."
|
||||
assert error.context["response_data"].endswith("...")
|
||||
|
||||
def test_api_error_with_short_response(self):
|
||||
"""Test API error with short response data"""
|
||||
short_response = "Short error"
|
||||
error = APIError("API error", response_data=short_response)
|
||||
|
||||
assert error.context["response_data"] == short_response
|
||||
|
||||
|
||||
class TestValidationError:
|
||||
"""Test ValidationError class"""
|
||||
|
||||
def test_basic_validation_error(self):
|
||||
"""Test basic validation error"""
|
||||
error = ValidationError(
|
||||
"Invalid email",
|
||||
field_name="email",
|
||||
field_value="invalid-email",
|
||||
validation_rule="email_format"
|
||||
)
|
||||
|
||||
assert error.message == "Invalid email"
|
||||
assert error.context["field_name"] == "email"
|
||||
assert error.context["field_value"] == "invalid-email"
|
||||
assert error.context["validation_rule"] == "email_format"
|
||||
|
||||
def test_validation_error_with_sensitive_field(self):
|
||||
"""Test validation error with sensitive field value redaction"""
|
||||
error = ValidationError(
|
||||
"Invalid password",
|
||||
field_name="password",
|
||||
field_value="secret123"
|
||||
)
|
||||
|
||||
assert error.context["field_name"] == "password"
|
||||
assert error.context["field_value"] == "[REDACTED]"
|
||||
|
||||
def test_validation_error_with_long_value(self):
|
||||
"""Test validation error with long field value truncation"""
|
||||
long_value = "x" * 200
|
||||
error = ValidationError(
|
||||
"Invalid input",
|
||||
field_name="description",
|
||||
field_value=long_value
|
||||
)
|
||||
|
||||
assert len(error.context["field_value"]) == 100 # Truncated to 100 chars
|
||||
|
||||
|
||||
class TestFileSystemError:
|
||||
"""Test FileSystemError class"""
|
||||
|
||||
def test_basic_filesystem_error(self):
|
||||
"""Test basic filesystem error"""
|
||||
error = FileSystemError(
|
||||
"File not found",
|
||||
file_path="/path/to/file.txt",
|
||||
operation="read"
|
||||
)
|
||||
|
||||
assert error.message == "File not found"
|
||||
assert error.context["file_path"] == "/path/to/file.txt"
|
||||
assert error.context["operation"] == "read"
|
||||
|
||||
|
||||
class TestSecurityError:
|
||||
"""Test SecurityError class"""
|
||||
|
||||
def test_basic_security_error(self):
|
||||
"""Test basic security error"""
|
||||
error = SecurityError(
|
||||
"Path traversal detected",
|
||||
security_issue="path_traversal",
|
||||
attempted_path="../../../etc/passwd",
|
||||
risk_level="critical"
|
||||
)
|
||||
|
||||
assert error.message == "Path traversal detected"
|
||||
assert error.context["security_issue"] == "path_traversal"
|
||||
assert error.context["attempted_path"] == "../../../etc/passwd"
|
||||
assert error.context["risk_level"] == "critical"
|
||||
|
||||
def test_security_error_with_long_path(self):
|
||||
"""Test security error with long path truncation"""
|
||||
long_path = "/" + "x" * 600
|
||||
error = SecurityError("Security violation", attempted_path=long_path)
|
||||
|
||||
assert len(error.context["attempted_path"]) == 500 # Truncated
|
||||
|
||||
|
||||
class TestErrorContext:
|
||||
"""Test ErrorContext class"""
|
||||
|
||||
def test_valid_error_context(self):
|
||||
"""Test creating valid error context"""
|
||||
context = ErrorContext(
|
||||
component="test_component",
|
||||
operation="test_operation",
|
||||
user_message="User friendly message",
|
||||
technical_details={"key": "value"},
|
||||
severity="warning"
|
||||
)
|
||||
|
||||
assert context.component == "test_component"
|
||||
assert context.operation == "test_operation"
|
||||
assert context.user_message == "User friendly message"
|
||||
assert context.technical_details == {"key": "value"}
|
||||
assert context.severity == "warning"
|
||||
|
||||
def test_error_context_defaults(self):
|
||||
"""Test error context with default values"""
|
||||
context = ErrorContext(
|
||||
component="test_component",
|
||||
operation="test_operation"
|
||||
)
|
||||
|
||||
assert context.user_message == ""
|
||||
assert context.technical_details == {}
|
||||
assert context.severity == "error"
|
||||
|
||||
def test_error_context_validation_empty_component(self):
|
||||
"""Test error context validation with empty component"""
|
||||
with pytest.raises(ValueError, match="component cannot be empty"):
|
||||
ErrorContext(component="", operation="test_operation")
|
||||
|
||||
def test_error_context_validation_empty_operation(self):
|
||||
"""Test error context validation with empty operation"""
|
||||
with pytest.raises(ValueError, match="operation cannot be empty"):
|
||||
ErrorContext(component="test_component", operation="")
|
||||
|
||||
def test_error_context_validation_invalid_severity(self):
|
||||
"""Test error context validation with invalid severity"""
|
||||
with pytest.raises(ValueError, match="severity must be one of"):
|
||||
ErrorContext(
|
||||
component="test_component",
|
||||
operation="test_operation",
|
||||
severity="invalid"
|
||||
)
|
||||
|
||||
def test_error_context_validation_invalid_technical_details(self):
|
||||
"""Test error context validation with invalid technical_details"""
|
||||
with pytest.raises(ValueError, match="technical_details must be a dictionary"):
|
||||
ErrorContext(
|
||||
component="test_component",
|
||||
operation="test_operation",
|
||||
technical_details="not a dict"
|
||||
)
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test converting error context to dictionary"""
|
||||
context = ErrorContext(
|
||||
component="test_component",
|
||||
operation="test_operation",
|
||||
user_message="Test message",
|
||||
technical_details={"key": "value"},
|
||||
severity="info"
|
||||
)
|
||||
|
||||
context_dict = context.to_dict()
|
||||
|
||||
assert context_dict["component"] == "test_component"
|
||||
assert context_dict["operation"] == "test_operation"
|
||||
assert context_dict["user_message"] == "Test message"
|
||||
assert context_dict["technical_details"] == {"key": "value"}
|
||||
assert context_dict["severity"] == "info"
|
||||
|
||||
|
||||
class TestErrorHandler:
|
||||
"""Test ErrorHandler class"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures"""
|
||||
self.mock_logger = Mock(spec=logging.Logger)
|
||||
self.error_handler = ErrorHandler(self.mock_logger)
|
||||
|
||||
def test_error_handler_creation(self):
|
||||
"""Test creating error handler"""
|
||||
handler = ErrorHandler()
|
||||
assert handler.logger is not None
|
||||
|
||||
handler_with_logger = ErrorHandler(self.mock_logger)
|
||||
assert handler_with_logger.logger == self.mock_logger
|
||||
|
||||
def test_handle_error_with_custom_error(self):
|
||||
"""Test handling custom JournalOrganizerError"""
|
||||
error = ConfigurationError("Config error", config_key="api_key")
|
||||
context = ErrorContext(
|
||||
component="config",
|
||||
operation="load",
|
||||
user_message="Please check your configuration"
|
||||
)
|
||||
|
||||
result = self.error_handler.handle_error(error, context)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Config error"
|
||||
assert result["message"] == "Please check your configuration"
|
||||
assert result["component"] == "config"
|
||||
assert result["operation"] == "load"
|
||||
assert result["error_type"] == "ConfigurationError"
|
||||
assert "timestamp" in result
|
||||
|
||||
# Check that logger was called
|
||||
self.mock_logger.log.assert_called_once()
|
||||
|
||||
def test_handle_error_with_generic_error(self):
|
||||
"""Test handling generic Python exception"""
|
||||
error = ValueError("Generic error")
|
||||
context = ErrorContext(
|
||||
component="test",
|
||||
operation="test_op",
|
||||
severity="warning"
|
||||
)
|
||||
|
||||
result = self.error_handler.handle_error(error, context)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Generic error"
|
||||
assert result["error_type"] == "ValueError"
|
||||
|
||||
# Check that logger was called with warning level
|
||||
self.mock_logger.log.assert_called_once()
|
||||
call_args = self.mock_logger.log.call_args
|
||||
assert call_args[0][0] == logging.WARNING # Log level
|
||||
|
||||
def test_handle_api_error(self):
|
||||
"""Test handling API errors"""
|
||||
error = RuntimeError("Connection failed")
|
||||
|
||||
result = self.error_handler.handle_api_error(error, "claude", "analyze_text")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "claude API error" in result["error"]
|
||||
assert result["component"] == "claude_api"
|
||||
assert result["operation"] == "analyze_text"
|
||||
assert "Failed to communicate with claude" in result["message"]
|
||||
|
||||
def test_handle_api_error_with_api_error_instance(self):
|
||||
"""Test handling APIError instance"""
|
||||
api_error = APIError("API failed", api_name="obsidian", status_code=404)
|
||||
|
||||
result = self.error_handler.handle_api_error(api_error, "obsidian", "read_note")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "API failed"
|
||||
assert result["component"] == "obsidian_api"
|
||||
|
||||
def test_handle_validation_error(self):
|
||||
"""Test handling validation errors"""
|
||||
error = ValueError("Invalid format")
|
||||
|
||||
result = self.error_handler.handle_validation_error(error, "email", "validate_input")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Invalid email" in result["error"]
|
||||
assert result["component"] == "validation"
|
||||
assert result["operation"] == "validate_input"
|
||||
assert "Please check your email" in result["message"]
|
||||
|
||||
def test_handle_configuration_error(self):
|
||||
"""Test handling configuration errors"""
|
||||
error = ValueError("Missing key")
|
||||
|
||||
result = self.error_handler.handle_configuration_error(error, "api_key")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Configuration error for api_key" in result["error"]
|
||||
assert result["component"] == "configuration"
|
||||
assert result["operation"] == "load_config"
|
||||
assert "Please check your API key configuration" in result["message"]
|
||||
|
||||
def test_sanitize_error_message_api_key(self):
|
||||
"""Test sanitizing error messages with API keys"""
|
||||
message = "Error: api_key=sk-secret-key-123 is invalid"
|
||||
|
||||
sanitized = self.error_handler._sanitize_error_message(message)
|
||||
|
||||
assert "sk-secret-key-123" not in sanitized
|
||||
assert "api_key=[REDACTED]" in sanitized
|
||||
|
||||
def test_sanitize_error_message_bearer_token(self):
|
||||
"""Test sanitizing error messages with Bearer tokens"""
|
||||
message = "Authorization failed: Bearer abc123xyz789"
|
||||
|
||||
sanitized = self.error_handler._sanitize_error_message(message)
|
||||
|
||||
assert "abc123xyz789" not in sanitized
|
||||
assert "[REDACTED]" in sanitized
|
||||
|
||||
def test_sanitize_error_message_email(self):
|
||||
"""Test sanitizing error messages with email addresses"""
|
||||
message = "Failed to send email to user@example.com"
|
||||
|
||||
sanitized = self.error_handler._sanitize_error_message(message)
|
||||
|
||||
assert "user@example.com" not in sanitized
|
||||
assert "[EMAIL_REDACTED]" in sanitized
|
||||
|
||||
def test_sanitize_error_message_file_paths(self):
|
||||
"""Test sanitizing error messages with user file paths"""
|
||||
message = "Cannot access /Users/john/Documents/secret.txt"
|
||||
|
||||
sanitized = self.error_handler._sanitize_error_message(message)
|
||||
|
||||
assert "john" not in sanitized
|
||||
assert "[USER_REDACTED]" in sanitized
|
||||
|
||||
def test_sanitize_context_data(self):
|
||||
"""Test sanitizing context data"""
|
||||
data = {
|
||||
"api_key": "secret-key-123",
|
||||
"username": "john_doe",
|
||||
"password": "secret123",
|
||||
"timeout": 30,
|
||||
"nested": {
|
||||
"token": "bearer-token-xyz",
|
||||
"safe_value": "public_info"
|
||||
}
|
||||
}
|
||||
|
||||
sanitized = self.error_handler._sanitize_context_data(data)
|
||||
|
||||
assert sanitized["api_key"] == "[REDACTED]"
|
||||
assert sanitized["username"] == "john_doe" # Not sensitive
|
||||
assert sanitized["password"] == "[REDACTED]"
|
||||
assert sanitized["timeout"] == 30
|
||||
assert sanitized["nested"]["token"] == "[REDACTED]"
|
||||
assert sanitized["nested"]["safe_value"] == "public_info"
|
||||
|
||||
def test_sanitize_context_data_non_dict(self):
|
||||
"""Test sanitizing non-dictionary context data"""
|
||||
result = self.error_handler._sanitize_context_data("not a dict")
|
||||
assert result == "not a dict"
|
||||
|
||||
|
||||
class TestGlobalErrorHandler:
|
||||
"""Test global error handler functions"""
|
||||
|
||||
def test_get_error_handler_singleton(self):
|
||||
"""Test that get_error_handler returns singleton"""
|
||||
handler1 = get_error_handler()
|
||||
handler2 = get_error_handler()
|
||||
|
||||
assert handler1 is handler2
|
||||
|
||||
def test_set_error_handler(self):
|
||||
"""Test setting custom error handler"""
|
||||
custom_handler = ErrorHandler()
|
||||
set_error_handler(custom_handler)
|
||||
|
||||
retrieved_handler = get_error_handler()
|
||||
assert retrieved_handler is custom_handler
|
||||
|
||||
|
||||
class TestAuditErrorMessageSecurity:
|
||||
"""Test error message security auditing"""
|
||||
|
||||
def test_audit_clean_message(self):
|
||||
"""Test auditing clean message with no issues"""
|
||||
message = "Simple error message with no sensitive data"
|
||||
|
||||
result = audit_error_message_security(message)
|
||||
|
||||
assert result["has_issues"] is False
|
||||
assert result["issues"] == []
|
||||
assert result["risk_level"] == "low"
|
||||
|
||||
def test_audit_message_with_email(self):
|
||||
"""Test auditing message with email address"""
|
||||
message = "Failed to send notification to user@example.com"
|
||||
|
||||
result = audit_error_message_security(message)
|
||||
|
||||
assert result["has_issues"] is True
|
||||
assert len(result["issues"]) == 1
|
||||
assert result["issues"][0]["type"] == "email_address"
|
||||
assert result["risk_level"] == "medium"
|
||||
|
||||
def test_audit_message_with_api_key(self):
|
||||
"""Test auditing message with API key"""
|
||||
message = "Authentication failed: api_key=sk-secret-123"
|
||||
|
||||
result = audit_error_message_security(message)
|
||||
|
||||
assert result["has_issues"] is True
|
||||
assert any(issue["type"] == "credential_pattern" for issue in result["issues"])
|
||||
assert result["risk_level"] == "high"
|
||||
|
||||
def test_audit_message_with_bearer_token(self):
|
||||
"""Test auditing message with Bearer token"""
|
||||
message = "Authorization header: Bearer abc123xyz789"
|
||||
|
||||
result = audit_error_message_security(message)
|
||||
|
||||
assert result["has_issues"] is True
|
||||
assert any(issue["type"] == "bearer_token" for issue in result["issues"])
|
||||
assert result["risk_level"] == "high"
|
||||
|
||||
def test_audit_message_with_ip_address(self):
|
||||
"""Test auditing message with IP address"""
|
||||
message = "Connection failed to 192.168.1.100"
|
||||
|
||||
result = audit_error_message_security(message)
|
||||
|
||||
assert result["has_issues"] is True
|
||||
assert any(issue["type"] == "ip_address" for issue in result["issues"])
|
||||
assert result["risk_level"] == "medium"
|
||||
|
||||
def test_audit_message_with_multiple_issues(self):
|
||||
"""Test auditing message with multiple security issues"""
|
||||
message = "Failed to connect to 192.168.1.100 with api_key=secret123 for user@example.com"
|
||||
|
||||
result = audit_error_message_security(message)
|
||||
|
||||
assert result["has_issues"] is True
|
||||
assert len(result["issues"]) >= 2 # Should find multiple issues
|
||||
assert result["risk_level"] == "high" # High due to credential pattern
|
||||
Reference in New Issue
Block a user