Remove the implicit jpmschweitzer default tenant (DEFAULT_USER) from src/core/multi_tenancy.py and every endpoint and request model that inherited it (~40 endpoints across /query, /wiki, /vector, /graph, /ingest, /volatile, /documents, /stats, /rag). - Add validate_required_user() + RequiredUser pydantic type in multi_tenancy and a shared require_user FastAPI dependency (RequiredUserQuery) that rejects missing, empty, and whitespace-only users with 422, following the /maintenance/* pattern. - Wiki page create / smart-create / dossier request models now require user (no fallback in wiki_service). - /maintenance/cleanup/test-data derives the tenant from the page path instead of using the production tenant collection. - Wiki.js change listener skips changes when no tenant user can be derived from the notification email instead of defaulting to the production tenant. - Consolidation service internal helpers no longer default to the production tenant. - Tool catalog marks user as required with honest descriptions. - OpenAPI descriptions updated honestly; CHANGELOG notes that callers (tatlock, Scheduler ingest tasks) must now send explicit user. - Offline tests: 422 coverage for query/body endpoints, required-user validator tests; updated legacy tests that assumed a default tenant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
377 lines
12 KiB
Python
377 lines
12 KiB
Python
"""Tests for RAG search service and endpoints."""
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from src.models.rag_search import (
|
|
SearchType,
|
|
RAGSearchRequest,
|
|
RAGSearchResult,
|
|
RAGSearchResponse,
|
|
)
|
|
from src.services.rag_search_service import RAGSearchService, extract_domain
|
|
|
|
|
|
class TestExtractDomain:
|
|
"""Tests for domain extraction utility."""
|
|
|
|
def test_extract_simple_domain(self):
|
|
"""Test extracting domain from simple URL."""
|
|
assert extract_domain("https://example.com/page") == "example.com"
|
|
|
|
def test_extract_domain_with_www(self):
|
|
"""Test extracting domain removes www prefix."""
|
|
assert extract_domain("https://www.example.com/page") == "example.com"
|
|
|
|
def test_extract_domain_with_subdomain(self):
|
|
"""Test extracting domain preserves subdomains."""
|
|
assert extract_domain("https://blog.example.com/post") == "blog.example.com"
|
|
|
|
def test_extract_domain_invalid_url(self):
|
|
"""Test extracting domain from invalid URL returns empty string."""
|
|
# urlparse returns empty netloc for invalid URLs
|
|
assert extract_domain("not-a-url") == ""
|
|
|
|
|
|
class TestRAGSearchModels:
|
|
"""Tests for RAG search Pydantic models."""
|
|
|
|
def test_search_request_defaults(self):
|
|
"""Test RAGSearchRequest defaults (user is required, no default tenant)."""
|
|
request = RAGSearchRequest(query="test query", user="llm_tester")
|
|
|
|
assert request.query == "test query"
|
|
assert request.search_type == SearchType.WEB
|
|
assert request.limit == 10
|
|
assert request.user == "llm_tester"
|
|
|
|
def test_search_request_requires_user(self):
|
|
"""A request without an explicit user must be rejected."""
|
|
import pytest
|
|
with pytest.raises(ValueError):
|
|
RAGSearchRequest(query="test query")
|
|
|
|
def test_search_request_custom_values(self):
|
|
"""Test RAGSearchRequest with custom values."""
|
|
request = RAGSearchRequest(
|
|
query="news about AI",
|
|
search_type=SearchType.NEWS,
|
|
limit=5,
|
|
user="custom_user"
|
|
)
|
|
|
|
assert request.query == "news about AI"
|
|
assert request.search_type == SearchType.NEWS
|
|
assert request.limit == 5
|
|
assert request.user == "custom_user"
|
|
|
|
def test_search_result(self):
|
|
"""Test RAGSearchResult model."""
|
|
result = RAGSearchResult(
|
|
title="Test Article",
|
|
url="https://example.com/article",
|
|
content="Full article content",
|
|
snippet="Article snippet...",
|
|
source="example.com",
|
|
published_date="2024-01-15"
|
|
)
|
|
|
|
assert result.title == "Test Article"
|
|
assert result.source == "example.com"
|
|
assert result.published_date == "2024-01-15"
|
|
|
|
def test_search_response(self):
|
|
"""Test RAGSearchResponse model."""
|
|
response = RAGSearchResponse(
|
|
query="test",
|
|
search_type=SearchType.WEB,
|
|
results=[],
|
|
total_results=0,
|
|
search_time_ms=100,
|
|
sources_summary=""
|
|
)
|
|
|
|
assert response.query == "test"
|
|
assert response.total_results == 0
|
|
assert response.search_time_ms == 100
|
|
|
|
|
|
class TestRAGSearchService:
|
|
"""Tests for RAGSearchService."""
|
|
|
|
@pytest.fixture
|
|
def mock_searxng_client(self):
|
|
"""Create mock SearXNG client."""
|
|
client = MagicMock()
|
|
client.search_general = AsyncMock(return_value=[
|
|
{
|
|
"title": "Test Result 1",
|
|
"url": "https://example.com/1",
|
|
"content": "Snippet 1",
|
|
"publishedDate": "2024-01-15"
|
|
},
|
|
{
|
|
"title": "Test Result 2",
|
|
"url": "https://example.com/2",
|
|
"content": "Snippet 2",
|
|
"publishedDate": None
|
|
}
|
|
])
|
|
client.search_news = AsyncMock(return_value=[])
|
|
client.search_images = AsyncMock(return_value=[])
|
|
return client
|
|
|
|
@pytest.fixture
|
|
def mock_content_extractor(self):
|
|
"""Create mock ContentExtractor."""
|
|
from src.models.content import ContentExtractionResult
|
|
|
|
extractor = MagicMock()
|
|
extractor.extract_batch = AsyncMock(return_value=[
|
|
ContentExtractionResult(
|
|
url="https://example.com/1",
|
|
content="Full extracted content 1",
|
|
success=True
|
|
),
|
|
ContentExtractionResult(
|
|
url="https://example.com/2",
|
|
content="Full extracted content 2",
|
|
success=True
|
|
)
|
|
])
|
|
return extractor
|
|
|
|
@pytest.fixture
|
|
def mock_redis_client(self):
|
|
"""Create mock Redis client."""
|
|
redis = MagicMock()
|
|
redis.get = AsyncMock(return_value=None) # No cache hit
|
|
redis.setex = AsyncMock()
|
|
return redis
|
|
|
|
@pytest.fixture
|
|
def mock_settings(self):
|
|
"""Create mock settings."""
|
|
settings = MagicMock()
|
|
settings.search_cache_ttl = 300
|
|
settings.search_default_limit = 10
|
|
return settings
|
|
|
|
@pytest.fixture
|
|
def rag_search_service(
|
|
self,
|
|
mock_searxng_client,
|
|
mock_content_extractor,
|
|
mock_redis_client,
|
|
mock_settings
|
|
):
|
|
"""Create RAGSearchService with mocked dependencies."""
|
|
return RAGSearchService(
|
|
searxng_client=mock_searxng_client,
|
|
content_extractor=mock_content_extractor,
|
|
redis_client=mock_redis_client,
|
|
settings=mock_settings
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_basic(self, rag_search_service, mock_searxng_client):
|
|
"""Test basic web search."""
|
|
response = await rag_search_service.search(
|
|
query="test query",
|
|
search_type=SearchType.WEB,
|
|
limit=10
|
|
)
|
|
|
|
assert response.query == "test query"
|
|
assert response.search_type == SearchType.WEB
|
|
assert len(response.results) == 2
|
|
assert response.total_results == 2
|
|
assert response.search_time_ms >= 0
|
|
|
|
mock_searxng_client.search_general.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_news(self, rag_search_service, mock_searxng_client):
|
|
"""Test news search type."""
|
|
mock_searxng_client.search_news.return_value = [
|
|
{"title": "News", "url": "https://news.com/1", "content": "News content"}
|
|
]
|
|
|
|
response = await rag_search_service.search(
|
|
query="latest news",
|
|
search_type=SearchType.NEWS
|
|
)
|
|
|
|
assert response.search_type == SearchType.NEWS
|
|
mock_searxng_client.search_news.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_images(self, rag_search_service, mock_searxng_client):
|
|
"""Test image search type."""
|
|
mock_searxng_client.search_images.return_value = [
|
|
{"title": "Image", "url": "https://images.com/1.jpg", "content": ""}
|
|
]
|
|
|
|
response = await rag_search_service.search(
|
|
query="cat photos",
|
|
search_type=SearchType.IMAGES
|
|
)
|
|
|
|
assert response.search_type == SearchType.IMAGES
|
|
mock_searxng_client.search_images.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_empty_query(self, rag_search_service):
|
|
"""Test search with empty query raises ValueError."""
|
|
with pytest.raises(ValueError, match="Query cannot be empty"):
|
|
await rag_search_service.search(query="", search_type=SearchType.WEB)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_caching_miss(
|
|
self,
|
|
rag_search_service,
|
|
mock_redis_client,
|
|
mock_searxng_client
|
|
):
|
|
"""Test search caches results on cache miss."""
|
|
mock_redis_client.get.return_value = None # Cache miss
|
|
|
|
await rag_search_service.search(query="test", search_type=SearchType.WEB)
|
|
|
|
# Should call SearXNG (cache miss)
|
|
mock_searxng_client.search_general.assert_called_once()
|
|
# Should cache result
|
|
mock_redis_client.setex.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_caching_hit(
|
|
self,
|
|
rag_search_service,
|
|
mock_redis_client,
|
|
mock_searxng_client
|
|
):
|
|
"""Test search returns cached results on cache hit."""
|
|
# Simulate cache hit
|
|
cached_response = RAGSearchResponse(
|
|
query="test",
|
|
search_type=SearchType.WEB,
|
|
results=[],
|
|
total_results=0,
|
|
search_time_ms=50,
|
|
sources_summary=""
|
|
)
|
|
mock_redis_client.get.return_value = cached_response.model_dump_json()
|
|
|
|
response = await rag_search_service.search(query="test", search_type=SearchType.WEB)
|
|
|
|
# Should NOT call SearXNG (cache hit)
|
|
mock_searxng_client.search_general.assert_not_called()
|
|
assert response.query == "test"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_content_extraction(
|
|
self,
|
|
rag_search_service,
|
|
mock_content_extractor
|
|
):
|
|
"""Test search extracts content from result URLs."""
|
|
response = await rag_search_service.search(
|
|
query="test",
|
|
search_type=SearchType.WEB
|
|
)
|
|
|
|
# Should have called content extractor
|
|
mock_content_extractor.extract_batch.assert_called_once()
|
|
|
|
# Results should have extracted content
|
|
for result in response.results:
|
|
assert result.content # Content should be populated
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_sources_summary(self, rag_search_service):
|
|
"""Test search generates sources summary."""
|
|
response = await rag_search_service.search(
|
|
query="test",
|
|
search_type=SearchType.WEB
|
|
)
|
|
|
|
assert response.sources_summary
|
|
assert "## Sources" in response.sources_summary
|
|
assert "[Test Result 1]" in response.sources_summary
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_limit(self, rag_search_service, mock_searxng_client):
|
|
"""Test search respects limit parameter."""
|
|
await rag_search_service.search(
|
|
query="test",
|
|
search_type=SearchType.WEB,
|
|
limit=5
|
|
)
|
|
|
|
# Check limit was passed to SearXNG
|
|
mock_searxng_client.search_general.assert_called_once_with(
|
|
query="test",
|
|
limit=5
|
|
)
|
|
|
|
|
|
class TestRAGSearchServiceIntegration:
|
|
"""Integration-style tests (still mocked but test more of the flow)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_search_flow(self):
|
|
"""Test full search flow with all components mocked."""
|
|
from src.models.content import ContentExtractionResult
|
|
|
|
# Setup mocks
|
|
mock_searxng = MagicMock()
|
|
mock_searxng.search_general = AsyncMock(return_value=[
|
|
{
|
|
"title": "Python Tutorial",
|
|
"url": "https://python.org/tutorial",
|
|
"content": "Learn Python programming",
|
|
"publishedDate": "2024-01-10"
|
|
}
|
|
])
|
|
|
|
mock_extractor = MagicMock()
|
|
mock_extractor.extract_batch = AsyncMock(return_value=[
|
|
ContentExtractionResult(
|
|
url="https://python.org/tutorial",
|
|
title="Python Tutorial",
|
|
content="This is a comprehensive Python tutorial covering basics to advanced topics.",
|
|
success=True
|
|
)
|
|
])
|
|
|
|
mock_redis = MagicMock()
|
|
mock_redis.get = AsyncMock(return_value=None)
|
|
mock_redis.setex = AsyncMock()
|
|
|
|
mock_settings = MagicMock()
|
|
mock_settings.search_cache_ttl = 300
|
|
mock_settings.search_default_limit = 10
|
|
|
|
# Create service and execute search
|
|
service = RAGSearchService(
|
|
searxng_client=mock_searxng,
|
|
content_extractor=mock_extractor,
|
|
redis_client=mock_redis,
|
|
settings=mock_settings
|
|
)
|
|
|
|
response = await service.search(
|
|
query="python tutorial",
|
|
search_type=SearchType.WEB,
|
|
limit=10,
|
|
user="test_user"
|
|
)
|
|
|
|
# Verify response
|
|
assert response.query == "python tutorial"
|
|
assert len(response.results) == 1
|
|
assert response.results[0].title == "Python Tutorial"
|
|
assert response.results[0].source == "python.org"
|
|
assert "comprehensive Python tutorial" in response.results[0].content
|
|
assert response.results[0].snippet == "Learn Python programming"
|