Protocol tests (16): - AgentRequest/AgentResponse serialization - DelegationIntent and DelegationReason validation - CoordinationResult aggregation - Error type tests Coordination tests (14): - Engine initialization and agent availability - Delegation execution (success, error, timeout) - Multi-intent coordination - Streaming delegation Librarian tests (42): - Library-desk client (all endpoints) - Wiki operations (search, get, create, update) - Smart-create with HybridRAG - Capability registration - Response model validation Total: 72 new tests, all passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
599 lines
18 KiB
Python
599 lines
18 KiB
Python
"""
|
|
Tests for the Library-Desk HTTP client.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
import httpx
|
|
|
|
from src.agents.librarian.client import (
|
|
LibraryDeskClient,
|
|
HybridRAGResponse,
|
|
HybridSearchResult,
|
|
WikiPage,
|
|
WikiSearchResult,
|
|
VectorSearchResult,
|
|
GraphNode,
|
|
Dossier,
|
|
SmartCreateResponse,
|
|
ResearchSummary,
|
|
EntityLinking,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_httpx_client():
|
|
"""Create a mock httpx client."""
|
|
return AsyncMock(spec=httpx.AsyncClient)
|
|
|
|
|
|
@pytest.fixture
|
|
def client_with_mock(mock_httpx_client):
|
|
"""Create a LibraryDeskClient with mocked httpx client."""
|
|
client = LibraryDeskClient(
|
|
base_url="http://test:8089",
|
|
api_key="test-key",
|
|
)
|
|
client._client = mock_httpx_client
|
|
return client
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestLibraryDeskClientInit:
|
|
"""Tests for client initialization."""
|
|
|
|
def test_default_initialization(self):
|
|
"""Test client initializes with defaults from config."""
|
|
client = LibraryDeskClient()
|
|
|
|
assert client.base_url is not None
|
|
assert client.timeout == 60
|
|
assert client._client is None
|
|
|
|
def test_custom_initialization(self):
|
|
"""Test client with custom parameters."""
|
|
client = LibraryDeskClient(
|
|
base_url="http://custom:9000",
|
|
api_key="my-api-key",
|
|
timeout=120,
|
|
)
|
|
|
|
assert client.base_url == "http://custom:9000"
|
|
assert client.api_key == "my-api-key"
|
|
assert client.timeout == 120
|
|
|
|
def test_ensure_client_not_initialized(self):
|
|
"""Test _ensure_client raises when not in context."""
|
|
client = LibraryDeskClient()
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
client._ensure_client()
|
|
|
|
assert "not initialized" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestContextManager:
|
|
"""Tests for async context manager."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_manager_creates_client(self):
|
|
"""Test context manager creates httpx client."""
|
|
async with LibraryDeskClient(
|
|
base_url="http://test:8089",
|
|
api_key="test-key",
|
|
) as client:
|
|
assert client._client is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_manager_closes_client(self):
|
|
"""Test context manager closes client on exit."""
|
|
client = LibraryDeskClient(base_url="http://test:8089")
|
|
|
|
async with client:
|
|
assert client._client is not None
|
|
|
|
# After exit, client should be None
|
|
assert client._client is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestHybridSearch:
|
|
"""Tests for hybrid search."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hybrid_search_success(self, client_with_mock, mock_httpx_client):
|
|
"""Test successful hybrid search."""
|
|
# Mock response
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"results": [
|
|
{
|
|
"source": "vector",
|
|
"title": "Docker Guide",
|
|
"content": "Docker networking basics...",
|
|
"score": 0.95,
|
|
"page_id": 123,
|
|
}
|
|
],
|
|
"keywords": ["docker", "networking"],
|
|
"synonyms": ["container"],
|
|
"formatted_context": "Context here",
|
|
"timing": {"total": 1.5},
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
result = await client_with_mock.hybrid_search(
|
|
query="Docker networking",
|
|
user="testuser",
|
|
)
|
|
|
|
assert isinstance(result, HybridRAGResponse)
|
|
assert len(result.results) == 1
|
|
assert result.results[0].title == "Docker Guide"
|
|
assert result.results[0].source == "vector"
|
|
assert "docker" in result.keywords
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hybrid_search_empty_results(
|
|
self, client_with_mock, mock_httpx_client
|
|
):
|
|
"""Test hybrid search with no results."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"results": [],
|
|
"keywords": [],
|
|
"formatted_context": "",
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
result = await client_with_mock.hybrid_search("nonexistent query")
|
|
|
|
assert len(result.results) == 0
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWikiOperations:
|
|
"""Tests for wiki operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_wiki(self, client_with_mock, mock_httpx_client):
|
|
"""Test wiki search."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"results": [
|
|
{
|
|
"id": 1,
|
|
"path": "/docs/docker",
|
|
"title": "Docker Documentation",
|
|
"description": "Docker docs",
|
|
}
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
results = await client_with_mock.search_wiki("docker")
|
|
|
|
assert len(results) == 1
|
|
assert isinstance(results[0], WikiSearchResult)
|
|
assert results[0].title == "Docker Documentation"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_wiki_page(self, client_with_mock, mock_httpx_client):
|
|
"""Test getting a wiki page."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"id": 123,
|
|
"path": "/docs/docker",
|
|
"title": "Docker Guide",
|
|
"content": "# Docker\n\nFull content here...",
|
|
"tags": ["docker", "devops"],
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
page = await client_with_mock.get_wiki_page(123)
|
|
|
|
assert isinstance(page, WikiPage)
|
|
assert page.id == 123
|
|
assert page.title == "Docker Guide"
|
|
assert "docker" in page.tags
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_wiki_pages(self, client_with_mock, mock_httpx_client):
|
|
"""Test listing wiki pages."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"pages": [
|
|
{"id": 1, "path": "/page1", "title": "Page 1"},
|
|
{"id": 2, "path": "/page2", "title": "Page 2"},
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
pages = await client_with_mock.list_wiki_pages()
|
|
|
|
assert len(pages) == 2
|
|
assert pages[0].title == "Page 1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_dossiers(self, client_with_mock, mock_httpx_client):
|
|
"""Test listing dossiers."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"dossiers": [
|
|
{"name": "docker", "page_count": 10},
|
|
{"name": "kubernetes", "page_count": 5},
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
dossiers = await client_with_mock.list_dossiers()
|
|
|
|
assert len(dossiers) == 2
|
|
assert isinstance(dossiers[0], Dossier)
|
|
assert dossiers[0].name == "docker"
|
|
assert dossiers[0].page_count == 10
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSemanticSearch:
|
|
"""Tests for semantic/vector search."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_semantic_search(self, client_with_mock, mock_httpx_client):
|
|
"""Test semantic search."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"results": [
|
|
{
|
|
"page_id": 1,
|
|
"page_path": "/docs/networking",
|
|
"page_title": "Networking Guide",
|
|
"chunk_text": "Container networking...",
|
|
"score": 0.92,
|
|
"chunk_index": 0,
|
|
}
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
results = await client_with_mock.semantic_search("container networking")
|
|
|
|
assert len(results) == 1
|
|
assert isinstance(results[0], VectorSearchResult)
|
|
assert results[0].score == 0.92
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGraphOperations:
|
|
"""Tests for knowledge graph operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_graph(self, client_with_mock, mock_httpx_client):
|
|
"""Test executing a Cypher query."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"records": [
|
|
{"name": "Docker", "type": "Technology"},
|
|
{"name": "Kubernetes", "type": "Technology"},
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
records = await client_with_mock.query_graph(
|
|
"MATCH (n:Technology) RETURN n.name as name, n.type as type"
|
|
)
|
|
|
|
assert len(records) == 2
|
|
assert records[0]["name"] == "Docker"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_graph_nodes(self, client_with_mock, mock_httpx_client):
|
|
"""Test listing graph nodes."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"nodes": [
|
|
{
|
|
"id": "node1",
|
|
"labels": ["Technology"],
|
|
"properties": {"name": "Docker"},
|
|
}
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
nodes = await client_with_mock.list_graph_nodes()
|
|
|
|
assert len(nodes) == 1
|
|
assert isinstance(nodes[0], GraphNode)
|
|
assert nodes[0].id == "node1"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestHealthCheck:
|
|
"""Tests for health check."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_healthy(self, client_with_mock, mock_httpx_client):
|
|
"""Test health check returns true when healthy."""
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
result = await client_with_mock.health_check()
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_unhealthy(self, client_with_mock, mock_httpx_client):
|
|
"""Test health check returns false on error."""
|
|
mock_httpx_client.get.side_effect = httpx.ConnectError("Connection refused")
|
|
|
|
result = await client_with_mock.health_check()
|
|
|
|
assert result is False
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestResponseModels:
|
|
"""Tests for response model validation."""
|
|
|
|
def test_wiki_page_model(self):
|
|
"""Test WikiPage model."""
|
|
page = WikiPage(
|
|
id=1,
|
|
path="/test",
|
|
title="Test Page",
|
|
content="Content here",
|
|
tags=["tag1"],
|
|
)
|
|
|
|
assert page.id == 1
|
|
assert page.title == "Test Page"
|
|
|
|
def test_wiki_page_optional_fields(self):
|
|
"""Test WikiPage with minimal fields."""
|
|
page = WikiPage(id=1, path="/test", title="Test")
|
|
|
|
assert page.content is None
|
|
assert page.tags == []
|
|
|
|
def test_hybrid_search_result_model(self):
|
|
"""Test HybridSearchResult model."""
|
|
result = HybridSearchResult(
|
|
source="vector",
|
|
title="Title",
|
|
content="Content",
|
|
score=0.9,
|
|
)
|
|
|
|
assert result.source == "vector"
|
|
assert result.url is None
|
|
assert result.metadata == {}
|
|
|
|
def test_vector_search_result_model(self):
|
|
"""Test VectorSearchResult model."""
|
|
result = VectorSearchResult(
|
|
page_id=1,
|
|
page_path="/doc",
|
|
page_title="Doc",
|
|
chunk_text="Text chunk",
|
|
score=0.85,
|
|
chunk_index=0,
|
|
)
|
|
|
|
assert result.score == 0.85
|
|
assert result.chunk_index == 0
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestUpdateWikiPage:
|
|
"""Tests for update_wiki_page method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_wiki_page_content(self, client_with_mock, mock_httpx_client):
|
|
"""Test updating wiki page content."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"id": 42,
|
|
"path": "/docs/test",
|
|
"title": "Test Page",
|
|
"content": "# Updated\n\nNew content",
|
|
"tags": ["test"],
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.put.return_value = mock_response
|
|
|
|
page = await client_with_mock.update_wiki_page(
|
|
page_id=42,
|
|
content="# Updated\n\nNew content",
|
|
)
|
|
|
|
assert isinstance(page, WikiPage)
|
|
assert page.id == 42
|
|
assert "Updated" in page.content
|
|
mock_httpx_client.put.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_wiki_page_tags_only(self, client_with_mock, mock_httpx_client):
|
|
"""Test updating only tags (partial update)."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"id": 42,
|
|
"path": "/docs/test",
|
|
"title": "Test Page",
|
|
"tags": ["projects", "devops"],
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.put.return_value = mock_response
|
|
|
|
page = await client_with_mock.update_wiki_page(
|
|
page_id=42,
|
|
tags=["projects", "devops"],
|
|
)
|
|
|
|
assert page.tags == ["projects", "devops"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_wiki_page_multiple_fields(
|
|
self, client_with_mock, mock_httpx_client
|
|
):
|
|
"""Test updating multiple fields at once."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"id": 42,
|
|
"path": "/docs/test",
|
|
"title": "New Title",
|
|
"description": "New description",
|
|
"tags": ["updated"],
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.put.return_value = mock_response
|
|
|
|
page = await client_with_mock.update_wiki_page(
|
|
page_id=42,
|
|
title="New Title",
|
|
description="New description",
|
|
tags=["updated"],
|
|
)
|
|
|
|
assert page.title == "New Title"
|
|
assert page.description == "New description"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSmartCreateWikiPage:
|
|
"""Tests for smart_create_wiki_page method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_smart_create_basic(self, client_with_mock, mock_httpx_client):
|
|
"""Test basic smart create."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"page": {
|
|
"id": 123,
|
|
"path": "/users/test/technology/docker-compose",
|
|
"title": "Docker Compose",
|
|
"content": "# Docker Compose\n\nContent...",
|
|
"tags": ["technology", "devops"],
|
|
},
|
|
"research_summary": {
|
|
"wiki_results": 3,
|
|
"web_results": 8,
|
|
"graph_entities": 5,
|
|
"keywords_extracted": 12,
|
|
"timing_ms": 4500,
|
|
},
|
|
"sources_used": 11,
|
|
"search_id": "uuid-123",
|
|
"entity_linking": {
|
|
"forward_links": 5,
|
|
"backward_links": 3,
|
|
"pages_updated": 2,
|
|
},
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
result = await client_with_mock.smart_create_wiki_page(
|
|
topic="Docker Compose",
|
|
tags=["technology", "devops"],
|
|
)
|
|
|
|
assert isinstance(result, SmartCreateResponse)
|
|
assert result.page.id == 123
|
|
assert result.page.title == "Docker Compose"
|
|
assert result.sources_used == 11
|
|
assert result.research_summary.wiki_results == 3
|
|
assert result.research_summary.web_results == 8
|
|
assert result.entity_linking.forward_links == 5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_smart_create_with_options(self, client_with_mock, mock_httpx_client):
|
|
"""Test smart create with custom options."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"page": {
|
|
"id": 456,
|
|
"path": "/custom/path",
|
|
"title": "Custom Topic",
|
|
"tags": ["custom"],
|
|
},
|
|
"research_summary": {
|
|
"wiki_results": 5,
|
|
"web_results": 0, # Web disabled
|
|
"timing_ms": 2000,
|
|
},
|
|
"sources_used": 5,
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
result = await client_with_mock.smart_create_wiki_page(
|
|
topic="Custom Topic",
|
|
tags=["custom"],
|
|
path="/custom/path",
|
|
include_web_research=False,
|
|
)
|
|
|
|
assert result.page.path == "/custom/path"
|
|
assert result.research_summary.web_results == 0
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestNewResponseModels:
|
|
"""Tests for new response models."""
|
|
|
|
def test_research_summary_model(self):
|
|
"""Test ResearchSummary model."""
|
|
summary = ResearchSummary(
|
|
wiki_results=3,
|
|
web_results=5,
|
|
graph_entities=2,
|
|
keywords_extracted=10,
|
|
timing_ms=3000,
|
|
)
|
|
|
|
assert summary.wiki_results == 3
|
|
assert summary.timing_ms == 3000
|
|
|
|
def test_research_summary_defaults(self):
|
|
"""Test ResearchSummary default values."""
|
|
summary = ResearchSummary()
|
|
|
|
assert summary.wiki_results == 0
|
|
assert summary.timing_ms == 0
|
|
|
|
def test_entity_linking_model(self):
|
|
"""Test EntityLinking model."""
|
|
linking = EntityLinking(
|
|
forward_links=5,
|
|
backward_links=3,
|
|
pages_updated=2,
|
|
)
|
|
|
|
assert linking.forward_links == 5
|
|
assert linking.pages_updated == 2
|
|
|
|
def test_smart_create_response_model(self):
|
|
"""Test SmartCreateResponse model."""
|
|
page = WikiPage(id=1, path="/test", title="Test")
|
|
response = SmartCreateResponse(
|
|
page=page,
|
|
sources_used=10,
|
|
search_id="uuid-456",
|
|
)
|
|
|
|
assert response.page.id == 1
|
|
assert response.sources_used == 10
|
|
assert response.search_id == "uuid-456"
|