""" Tests for the Library-Desk HTTP client. """ from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from src.agents.librarian.client import ( Dossier, EntityLinking, GraphNode, HybridRAGResponse, HybridSearchResult, LibraryDeskClient, ResearchSummary, SmartCreateResponse, VectorSearchResult, WikiPage, WikiSearchResult, ) @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 # All tenant-scoped client methods with minimal call kwargs. Used to sweep # the explicit-user contract: every library-desk request must carry a # non-empty user (the service is removing its server-side default). TENANT_SCOPED_METHODS = [ ("hybrid_search", {"query": "q"}), ("search_wiki", {"query": "q"}), ("get_wiki_page", {"page_id": 1}), ("list_wiki_pages", {}), ("create_wiki_page", {"title": "t", "path": "/p", "content": "c"}), ("update_wiki_page", {"page_id": 1, "content": "c"}), ("smart_create_wiki_page", {"topic": "t", "tags": ["x"]}), ("list_dossiers", {}), ("semantic_search", {"query": "q"}), ("query_graph", {"cypher_query": "MATCH (n) RETURN n"}), ("list_graph_nodes", {}), ("get_graph_node", {"node_id": "n1"}), ("search_web", {"query": "q"}), ("extract_content", {"url": "http://example.com"}), ("extract_content_batch", {"urls": ["http://example.com"]}), ] # One permissive response body that satisfies every method's parser # (extra keys are ignored by the pydantic models). UNIVERSAL_RESPONSE = { "id": 1, "path": "/p", "title": "T", "results": [], "pages": [], "dossiers": [], "records": [], "nodes": [], "keywords": [], "page": {"id": 1, "path": "/p", "title": "T"}, "result": {"url": "http://example.com", "success": True}, } @pytest.mark.unit class TestExplicitUserContract: """Every library-desk request sends a non-empty user explicitly.""" def _wire_client(self): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = UNIVERSAL_RESPONSE mock_response.raise_for_status = MagicMock() mock_httpx = AsyncMock(spec=httpx.AsyncClient) mock_httpx.get.return_value = mock_response mock_httpx.post.return_value = mock_response mock_httpx.put.return_value = mock_response client = LibraryDeskClient(base_url="http://test:8089", api_key="k") client._client = mock_httpx return client, mock_httpx def _sent_user(self, mock_httpx) -> str: """Extract the user sent on the single outgoing request.""" calls = ( mock_httpx.get.call_args_list + mock_httpx.post.call_args_list + mock_httpx.put.call_args_list ) assert len(calls) == 1, "expected exactly one outgoing request" kwargs = calls[0].kwargs params = kwargs.get("params") or {} payload = kwargs.get("json") or {} return params.get("user") or payload.get("user") or "" @pytest.mark.asyncio @pytest.mark.parametrize("method_name,kwargs", TENANT_SCOPED_METHODS) async def test_user_from_context_is_sent_on_the_wire(self, method_name, kwargs): """With no explicit user, the context user is resolved and sent.""" client, mock_httpx = self._wire_client() with patch("src.agents.librarian.client.get_user", return_value="llm_tester"): await getattr(client, method_name)(**kwargs) assert self._sent_user(mock_httpx) == "llm_tester" @pytest.mark.asyncio @pytest.mark.parametrize("method_name,kwargs", TENANT_SCOPED_METHODS) async def test_explicit_user_is_sent_on_the_wire(self, method_name, kwargs): """An explicitly passed user is sent unchanged.""" client, mock_httpx = self._wire_client() await getattr(client, method_name)(user="test_phase_b", **kwargs) assert self._sent_user(mock_httpx) == "test_phase_b" @pytest.mark.asyncio @pytest.mark.parametrize("method_name,kwargs", TENANT_SCOPED_METHODS) async def test_empty_context_user_fails_before_any_request(self, method_name, kwargs): """An empty resolved user raises before any bytes hit the wire.""" client, mock_httpx = self._wire_client() with patch("src.agents.librarian.client.get_user", return_value=""): with pytest.raises(ValueError, match="non-empty user"): await getattr(client, method_name)(**kwargs) mock_httpx.get.assert_not_called() mock_httpx.post.assert_not_called() mock_httpx.put.assert_not_called() @pytest.mark.asyncio async def test_explicit_whitespace_user_is_rejected(self): """A whitespace-only explicit user is rejected.""" client, mock_httpx = self._wire_client() with pytest.raises(ValueError, match="non-empty user"): await client.hybrid_search("q", user=" ") mock_httpx.post.assert_not_called() @pytest.mark.asyncio async def test_explicit_padded_user_is_stripped_on_the_wire(self): """Padded explicit users are stripped, not sent verbatim.""" client, mock_httpx = self._wire_client() await client.hybrid_search("q", user=" llm_tester ") assert self._sent_user(mock_httpx) == "llm_tester" @pytest.mark.asyncio @pytest.mark.parametrize( "explicit_user", ["jpmschweitzer", "JPMSchweitzer", "jpmschweitzer.", " jpmschweitzer"], ) @pytest.mark.parametrize("method_name,kwargs", TENANT_SCOPED_METHODS) async def test_explicit_production_tenant_is_guarded_in_dev( self, monkeypatch, method_name, kwargs, explicit_user ): """ An explicit production-tenant argument (or a sanitization-collision variant) never reaches library-desk from a non-production environment - the client applies the same tenant guard as context resolution. """ from src.core import config as config_module from src.core.config import Environment monkeypatch.setattr(config_module.config, "ENVIRONMENT", Environment.DEVELOPMENT) client, mock_httpx = self._wire_client() await getattr(client, method_name)(user=explicit_user, **kwargs) assert self._sent_user(mock_httpx) == "llm_tester" @pytest.mark.asyncio async def test_explicit_production_tenant_passes_through_in_prod(self, monkeypatch): """In production the production tenant is sent unchanged.""" from src.core import config as config_module from src.core.config import Environment monkeypatch.setattr(config_module.config, "ENVIRONMENT", Environment.PRODUCTION) client, mock_httpx = self._wire_client() await client.hybrid_search("q", user="jpmschweitzer") assert self._sent_user(mock_httpx) == "jpmschweitzer" @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"