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 @@
# Unit tests package
+457
View File
@@ -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
+710
View File
@@ -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'
+526
View File
@@ -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