- 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
458 lines
16 KiB
Python
458 lines
16 KiB
Python
"""
|
|
Integration tests for Obsidian Skills.
|
|
Tests Skills with mocked API responses to verify end-to-end functionality.
|
|
"""
|
|
import pytest
|
|
import json
|
|
from unittest.mock import AsyncMock, patch, Mock
|
|
from aioresponses import aioresponses
|
|
|
|
from agent_core import CommandContext, SkillResult
|
|
from skills.obsidian_skill import (
|
|
ObsidianReadSkill, ObsidianWriteSkill, ObsidianAppendSkill,
|
|
ObsidianListFilesSkill
|
|
)
|
|
|
|
|
|
class TestObsidianReadSkill:
|
|
"""Integration tests for ObsidianReadSkill"""
|
|
|
|
@pytest.fixture
|
|
def skill(self):
|
|
"""Create ObsidianReadSkill instance"""
|
|
return ObsidianReadSkill()
|
|
|
|
@pytest.fixture
|
|
def context(self, sample_config):
|
|
"""Create command context with Obsidian configuration"""
|
|
return CommandContext(
|
|
command_name="test_read",
|
|
args={"file_path": "Daily/2024-01-15.md"},
|
|
config=sample_config
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_note_success(self, skill, context):
|
|
"""Test successful note reading"""
|
|
mock_response = {
|
|
"content": "# 2024-01-15 Daily Journal\n\nTest content",
|
|
"stat": {
|
|
"ctime": 1642204800000,
|
|
"mtime": 1642204800000,
|
|
"size": 45
|
|
}
|
|
}
|
|
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/2024-01-15.md",
|
|
payload=mock_response,
|
|
status=200
|
|
)
|
|
|
|
result = await skill.execute(context)
|
|
|
|
assert result.success is True
|
|
assert result.data["content"] == mock_response["content"]
|
|
assert result.data["file_path"] == "Daily/2024-01-15.md"
|
|
assert "stat" in result.data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_note_not_found(self, skill, context):
|
|
"""Test reading non-existent note"""
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/2024-01-15.md",
|
|
status=404,
|
|
payload={"error": "File not found"}
|
|
)
|
|
|
|
result = await skill.execute(context)
|
|
|
|
assert result.success is False
|
|
assert "not found" in result.error.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_note_api_error(self, skill, context):
|
|
"""Test API connection error"""
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/2024-01-15.md",
|
|
exception=Exception("Connection failed")
|
|
)
|
|
|
|
result = await skill.execute(context)
|
|
|
|
assert result.success is False
|
|
assert "connection" in result.error.lower() or "api" in result.error.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_note_invalid_response(self, skill, context):
|
|
"""Test handling of invalid API response"""
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/2024-01-15.md",
|
|
payload="invalid json response",
|
|
status=200
|
|
)
|
|
|
|
result = await skill.execute(context)
|
|
|
|
assert result.success is False
|
|
assert "response" in result.error.lower()
|
|
|
|
|
|
class TestObsidianWriteSkill:
|
|
"""Integration tests for ObsidianWriteSkill"""
|
|
|
|
@pytest.fixture
|
|
def skill(self):
|
|
"""Create ObsidianWriteSkill instance"""
|
|
return ObsidianWriteSkill()
|
|
|
|
@pytest.fixture
|
|
def context(self, sample_config):
|
|
"""Create command context with write parameters"""
|
|
return CommandContext(
|
|
command_name="test_write",
|
|
args={
|
|
"file_path": "Knowledge/Experiences/test-experience.md",
|
|
"content": "# Test Experience\n\nThis is a test experience note."
|
|
},
|
|
config=sample_config
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_success(self, skill, context):
|
|
"""Test successful note writing"""
|
|
mock_response = {
|
|
"path": "Knowledge/Experiences/test-experience.md",
|
|
"stat": {
|
|
"ctime": 1642204800000,
|
|
"mtime": 1642204800000,
|
|
"size": 45
|
|
}
|
|
}
|
|
|
|
with aioresponses() as m:
|
|
m.put(
|
|
"https://localhost:27123/vault/Knowledge/Experiences/test-experience.md",
|
|
payload=mock_response,
|
|
status=200
|
|
)
|
|
|
|
result = await skill.execute(context)
|
|
|
|
assert result.success is True
|
|
assert result.data["file_path"] == "Knowledge/Experiences/test-experience.md"
|
|
assert "created" in result.message.lower() or "written" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_permission_error(self, skill, context):
|
|
"""Test write permission error"""
|
|
with aioresponses() as m:
|
|
m.put(
|
|
"https://localhost:27123/vault/Knowledge/Experiences/test-experience.md",
|
|
status=403,
|
|
payload={"error": "Permission denied"}
|
|
)
|
|
|
|
result = await skill.execute(context)
|
|
|
|
assert result.success is False
|
|
assert "permission" in result.error.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_note_missing_content(self, skill, sample_config):
|
|
"""Test writing note without content"""
|
|
context = CommandContext(
|
|
command_name="test_write",
|
|
args={"file_path": "test.md"}, # Missing content
|
|
config=sample_config
|
|
)
|
|
|
|
result = await skill.execute(context)
|
|
|
|
assert result.success is False
|
|
assert "content" in result.error.lower()
|
|
|
|
|
|
class TestObsidianAppendSkill:
|
|
"""Integration tests for ObsidianAppendSkill"""
|
|
|
|
@pytest.fixture
|
|
def skill(self):
|
|
"""Create ObsidianAppendSkill instance"""
|
|
return ObsidianAppendSkill()
|
|
|
|
@pytest.fixture
|
|
def context(self, sample_config):
|
|
"""Create command context with append parameters"""
|
|
return CommandContext(
|
|
command_name="test_append",
|
|
args={
|
|
"file_path": "Daily/2024-01-15.md",
|
|
"content": "\n\n## Additional Notes\n\nAppended content."
|
|
},
|
|
config=sample_config
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_append_to_existing_note_success(self, skill, context):
|
|
"""Test successful content appending to existing note"""
|
|
# Mock reading existing content
|
|
existing_content = "# 2024-01-15 Daily Journal\n\nExisting content"
|
|
read_response = {
|
|
"content": existing_content,
|
|
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 45}
|
|
}
|
|
|
|
# Mock writing updated content
|
|
write_response = {
|
|
"path": "Daily/2024-01-15.md",
|
|
"stat": {"ctime": 1642204800000, "mtime": 1642204900000, "size": 90}
|
|
}
|
|
|
|
with aioresponses() as m:
|
|
# Mock GET request for reading existing content
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/2024-01-15.md",
|
|
payload=read_response,
|
|
status=200
|
|
)
|
|
|
|
# Mock PUT request for writing updated content
|
|
m.put(
|
|
"https://localhost:27123/vault/Daily/2024-01-15.md",
|
|
payload=write_response,
|
|
status=200
|
|
)
|
|
|
|
result = await skill.execute(context)
|
|
|
|
assert result.success is True
|
|
assert result.data["file_path"] == "Daily/2024-01-15.md"
|
|
assert "appended" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_append_to_nonexistent_note(self, skill, context):
|
|
"""Test appending to non-existent note (should create new note)"""
|
|
write_response = {
|
|
"path": "Daily/2024-01-15.md",
|
|
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 45}
|
|
}
|
|
|
|
with aioresponses() as m:
|
|
# Mock GET request returning 404 (file doesn't exist)
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/2024-01-15.md",
|
|
status=404
|
|
)
|
|
|
|
# Mock PUT request for creating new file
|
|
m.put(
|
|
"https://localhost:27123/vault/Daily/2024-01-15.md",
|
|
payload=write_response,
|
|
status=200
|
|
)
|
|
|
|
result = await skill.execute(context)
|
|
|
|
assert result.success is True
|
|
assert "created" in result.message.lower()
|
|
|
|
|
|
class TestObsidianListFilesSkill:
|
|
"""Integration tests for ObsidianListFilesSkill"""
|
|
|
|
@pytest.fixture
|
|
def skill(self):
|
|
"""Create ObsidianListFilesSkill instance"""
|
|
return ObsidianListFilesSkill()
|
|
|
|
@pytest.fixture
|
|
def context(self, sample_config):
|
|
"""Create command context with list parameters"""
|
|
return CommandContext(
|
|
command_name="test_list",
|
|
args={"folder_path": "Daily"},
|
|
config=sample_config
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_files_success(self, skill, context):
|
|
"""Test successful file listing"""
|
|
mock_response = {
|
|
"files": [
|
|
{
|
|
"path": "Daily/2024-01-15.md",
|
|
"name": "2024-01-15.md",
|
|
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 45}
|
|
},
|
|
{
|
|
"path": "Daily/2024-01-14.md",
|
|
"name": "2024-01-14.md",
|
|
"stat": {"ctime": 1642118400000, "mtime": 1642118400000, "size": 38}
|
|
}
|
|
]
|
|
}
|
|
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/",
|
|
payload=mock_response,
|
|
status=200
|
|
)
|
|
|
|
result = await skill.execute(context)
|
|
|
|
assert result.success is True
|
|
assert len(result.data["files"]) == 2
|
|
assert result.data["folder_path"] == "Daily"
|
|
assert any(file["name"] == "2024-01-15.md" for file in result.data["files"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_files_empty_folder(self, skill, context):
|
|
"""Test listing files in empty folder"""
|
|
mock_response = {"files": []}
|
|
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/",
|
|
payload=mock_response,
|
|
status=200
|
|
)
|
|
|
|
result = await skill.execute(context)
|
|
|
|
assert result.success is True
|
|
assert len(result.data["files"]) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_files_folder_not_found(self, skill, context):
|
|
"""Test listing files in non-existent folder"""
|
|
with aioresponses() as m:
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/",
|
|
status=404,
|
|
payload={"error": "Folder not found"}
|
|
)
|
|
|
|
result = await skill.execute(context)
|
|
|
|
assert result.success is False
|
|
assert "not found" in result.error.lower()
|
|
|
|
|
|
class TestObsidianSkillsIntegration:
|
|
"""Integration tests combining multiple Obsidian skills"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_write_workflow(self, sample_config):
|
|
"""Test complete read-modify-write workflow"""
|
|
read_skill = ObsidianReadSkill()
|
|
write_skill = ObsidianWriteSkill()
|
|
|
|
# Read existing content
|
|
read_context = CommandContext(
|
|
command_name="read",
|
|
args={"file_path": "Daily/2024-01-15.md"},
|
|
config=sample_config
|
|
)
|
|
|
|
# Write modified content
|
|
write_context = CommandContext(
|
|
command_name="write",
|
|
args={
|
|
"file_path": "Knowledge/Processed/2024-01-15-summary.md",
|
|
"content": "# Summary\n\nProcessed content from daily journal."
|
|
},
|
|
config=sample_config
|
|
)
|
|
|
|
with aioresponses() as m:
|
|
# Mock read response
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/2024-01-15.md",
|
|
payload={
|
|
"content": "# 2024-01-15 Daily Journal\n\nOriginal content",
|
|
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 45}
|
|
},
|
|
status=200
|
|
)
|
|
|
|
# Mock write response
|
|
m.put(
|
|
"https://localhost:27123/vault/Knowledge/Processed/2024-01-15-summary.md",
|
|
payload={
|
|
"path": "Knowledge/Processed/2024-01-15-summary.md",
|
|
"stat": {"ctime": 1642204900000, "mtime": 1642204900000, "size": 60}
|
|
},
|
|
status=200
|
|
)
|
|
|
|
# Execute read
|
|
read_result = await read_skill.execute(read_context)
|
|
assert read_result.success is True
|
|
|
|
# Execute write (in real scenario, content would be processed)
|
|
write_result = await write_skill.execute(write_context)
|
|
assert write_result.success is True
|
|
|
|
# Verify workflow completed successfully
|
|
assert read_result.data["content"] is not None
|
|
assert write_result.data["file_path"] == "Knowledge/Processed/2024-01-15-summary.md"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_and_read_multiple_files(self, sample_config):
|
|
"""Test listing files and reading multiple files"""
|
|
list_skill = ObsidianListFilesSkill()
|
|
read_skill = ObsidianReadSkill()
|
|
|
|
list_context = CommandContext(
|
|
command_name="list",
|
|
args={"folder_path": "Daily"},
|
|
config=sample_config
|
|
)
|
|
|
|
with aioresponses() as m:
|
|
# Mock list response
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/",
|
|
payload={
|
|
"files": [
|
|
{"path": "Daily/2024-01-15.md", "name": "2024-01-15.md"},
|
|
{"path": "Daily/2024-01-14.md", "name": "2024-01-14.md"}
|
|
]
|
|
},
|
|
status=200
|
|
)
|
|
|
|
# Mock read responses for each file
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/2024-01-15.md",
|
|
payload={"content": "Content 1", "stat": {}},
|
|
status=200
|
|
)
|
|
m.get(
|
|
"https://localhost:27123/vault/Daily/2024-01-14.md",
|
|
payload={"content": "Content 2", "stat": {}},
|
|
status=200
|
|
)
|
|
|
|
# List files
|
|
list_result = await list_skill.execute(list_context)
|
|
assert list_result.success is True
|
|
assert len(list_result.data["files"]) == 2
|
|
|
|
# Read each file
|
|
for file_info in list_result.data["files"]:
|
|
read_context = CommandContext(
|
|
command_name="read",
|
|
args={"file_path": file_info["path"]},
|
|
config=sample_config
|
|
)
|
|
|
|
read_result = await read_skill.execute(read_context)
|
|
assert read_result.success is True
|
|
assert read_result.data["content"] is not None |