""" 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"