LibraryDeskClient._resolve_user only enforced non-empty: an explicit user argument to any tenant-scoped method bypassed tatlock's tenant guard entirely and went straight to library-desk, and padded values were sent un-stripped on the wire. Route the explicit-arg path through the same apply_tenant_guard() used by context resolution and strip whitespace before the empty check, so a non-production environment can never send the production tenant (or a sanitization-collision variant) to library-desk, regardless of how the user was supplied. Defense in depth - no in-repo caller passes an explicit user today. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
775 lines
25 KiB
Python
775 lines
25 KiB
Python
"""
|
|
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"
|