Initial commit: library-desk service extraction from portainer-core
Build and Push / build (release) Successful in 36s
Build and Push / build (release) Successful in 36s
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests for Library Desk service."""
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Pytest configuration and shared fixtures for Library Desk tests."""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from typing import AsyncGenerator
|
||||
|
||||
# Test configuration
|
||||
pytest_plugins = ("pytest_asyncio",)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user() -> str:
|
||||
"""Default test user."""
|
||||
return "test_user"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def neo4j_test_uri() -> str:
|
||||
"""Test Neo4j URI."""
|
||||
return "bolt://neo4j:7687"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def neo4j_test_auth() -> tuple:
|
||||
"""Test Neo4j authentication."""
|
||||
return ("neo4j", "test_password")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qdrant_test_url() -> str:
|
||||
"""Test Qdrant URL."""
|
||||
return "http://qdrant:6333"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wikijs_test_config() -> dict:
|
||||
"""Test Wiki.js configuration."""
|
||||
return {
|
||||
"base_url": "http://wiki:3000",
|
||||
"api_key": "test_api_key"
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def searxng_test_url() -> str:
|
||||
"""Test SearXNG URL."""
|
||||
return "http://searxng:8080"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ollama_test_config() -> dict:
|
||||
"""Test Ollama configuration."""
|
||||
return {
|
||||
"base_url": "http://ollama:11434",
|
||||
"model": "nomic-embed-text"
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def redis_test_url() -> str:
|
||||
"""Test Redis URL."""
|
||||
return "redis://redis-shared:6379/4"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_document() -> dict:
|
||||
"""Sample document for testing."""
|
||||
return {
|
||||
"id": "test_doc_1",
|
||||
"title": "Test Document",
|
||||
"content": "This is a test document for unit testing.",
|
||||
"metadata": {
|
||||
"source": "test",
|
||||
"author": "test_user"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_chunks() -> list:
|
||||
"""Sample document chunks for testing."""
|
||||
return [
|
||||
{
|
||||
"content": "First chunk of text.",
|
||||
"metadata": {"chunk_index": 0}
|
||||
},
|
||||
{
|
||||
"content": "Second chunk of text.",
|
||||
"metadata": {"chunk_index": 1}
|
||||
},
|
||||
{
|
||||
"content": "Third chunk of text.",
|
||||
"metadata": {"chunk_index": 2}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_embeddings() -> list:
|
||||
"""Sample embeddings for testing (768-dimensional for nomic-embed-text)."""
|
||||
import random
|
||||
random.seed(42) # Reproducible embeddings
|
||||
|
||||
# Generate 3 sample 768-dimensional embeddings
|
||||
return [
|
||||
[random.random() for _ in range(768)],
|
||||
[random.random() for _ in range(768)],
|
||||
[random.random() for _ in range(768)]
|
||||
]
|
||||
@@ -0,0 +1,754 @@
|
||||
"""
|
||||
Comprehensive tests for Knowledge Consolidation system.
|
||||
|
||||
Tests cover:
|
||||
- ConsolidationService (unit tests with mocks)
|
||||
- Consolidation API endpoint (integration tests)
|
||||
- Model validation
|
||||
- Error handling
|
||||
- Dry run mode
|
||||
|
||||
Run with: pytest tests/test_consolidation.py -v -s
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from typing import AsyncGenerator
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from src.services.consolidation_service import ConsolidationService
|
||||
from src.models.consolidation import (
|
||||
ConsolidationRequest,
|
||||
ConsolidationResponse,
|
||||
ConsolidationResult,
|
||||
SearchQueryInfo
|
||||
)
|
||||
|
||||
# Test constants
|
||||
TEST_USER = "consolidation-tester"
|
||||
TEST_SEARCH_ID = "test-search-123"
|
||||
|
||||
|
||||
# Fixtures
|
||||
|
||||
@pytest.fixture
|
||||
def settings():
|
||||
"""Get mocked application settings for testing."""
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.reranker_model = "mistral-nemo"
|
||||
mock_settings.ollama_model = "mistral-nemo"
|
||||
return mock_settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_neo4j():
|
||||
"""Mock Neo4j client."""
|
||||
mock = AsyncMock()
|
||||
mock.execute_query = AsyncMock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ollama():
|
||||
"""Mock Ollama client."""
|
||||
mock = AsyncMock()
|
||||
mock.generate_text = AsyncMock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_wiki():
|
||||
"""Mock Wiki.js client."""
|
||||
mock = AsyncMock()
|
||||
# Default mock for taxonomy structure
|
||||
mock.get_taxonomy_structure = AsyncMock(return_value={
|
||||
"companies": [],
|
||||
"people": [],
|
||||
"places": ["the-netherlands"],
|
||||
"reference": ["political-entities", "tech"],
|
||||
"technology": ["tools", "services"]
|
||||
})
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ingestion():
|
||||
"""Mock Ingestion service."""
|
||||
mock = AsyncMock()
|
||||
mock.ingest_page = AsyncMock(return_value=MagicMock(success=True))
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def consolidation_service(mock_neo4j, mock_ollama, mock_wiki, mock_ingestion, settings):
|
||||
"""Get ConsolidationService with mocked dependencies."""
|
||||
return ConsolidationService(
|
||||
neo4j=mock_neo4j,
|
||||
ollama=mock_ollama,
|
||||
wiki=mock_wiki,
|
||||
settings=settings,
|
||||
ingestion_service=mock_ingestion
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def consolidation_service_no_ingestion(mock_neo4j, mock_ollama, mock_wiki, settings):
|
||||
"""Get ConsolidationService without ingestion service (legacy behavior)."""
|
||||
return ConsolidationService(
|
||||
neo4j=mock_neo4j,
|
||||
ollama=mock_ollama,
|
||||
wiki=mock_wiki,
|
||||
settings=settings,
|
||||
ingestion_service=None
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_unprocessed_searches():
|
||||
"""Sample unprocessed search queries."""
|
||||
return [
|
||||
{
|
||||
'id': 'search-1',
|
||||
'query': 'docker orchestration kubernetes',
|
||||
'user': TEST_USER,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'total_results': 10,
|
||||
'web_count': 5,
|
||||
'keywords': ['docker', 'orchestration', 'kubernetes']
|
||||
},
|
||||
{
|
||||
'id': 'search-2',
|
||||
'query': 'python async programming',
|
||||
'user': TEST_USER,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'total_results': 8,
|
||||
'web_count': 3,
|
||||
'keywords': ['python', 'async', 'programming']
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_web_results():
|
||||
"""Sample web search results."""
|
||||
return [
|
||||
{
|
||||
'url': 'https://kubernetes.io/docs',
|
||||
'title': 'Kubernetes Documentation',
|
||||
'content': 'Kubernetes is an orchestration platform for containers...',
|
||||
'rank': 1,
|
||||
'rrf_score': 0.05
|
||||
},
|
||||
{
|
||||
'url': 'https://docs.docker.com/swarm',
|
||||
'title': 'Docker Swarm Documentation',
|
||||
'content': 'Docker Swarm is a container orchestration tool...',
|
||||
'rank': 2,
|
||||
'rrf_score': 0.04
|
||||
},
|
||||
{
|
||||
'url': 'https://example.com/k8s-tutorial',
|
||||
'title': 'Kubernetes Tutorial',
|
||||
'content': 'Learn how to use Kubernetes for container orchestration...',
|
||||
'rank': 3,
|
||||
'rrf_score': 0.03
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_llm_analysis():
|
||||
"""Sample LLM analysis response."""
|
||||
return {
|
||||
"has_novel_info": True,
|
||||
"new_pages": [
|
||||
{
|
||||
"title": "Kubernetes Container Orchestration",
|
||||
"path": "infrastructure/kubernetes",
|
||||
"summary": "Overview of Kubernetes orchestration capabilities"
|
||||
}
|
||||
],
|
||||
"update_pages": [
|
||||
{
|
||||
"title": "Docker Infrastructure",
|
||||
"new_facts": [
|
||||
"Kubernetes provides automatic bin packing",
|
||||
"Self-healing capabilities with automatic restarts"
|
||||
],
|
||||
"source_url": "https://kubernetes.io/docs"
|
||||
}
|
||||
],
|
||||
"new_entities": [
|
||||
{
|
||||
"name": "Kubernetes",
|
||||
"type": "technology",
|
||||
"description": "Container orchestration platform"
|
||||
},
|
||||
{
|
||||
"name": "Docker Swarm",
|
||||
"type": "technology",
|
||||
"description": "Docker's native orchestration tool"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# Model Tests
|
||||
|
||||
def test_consolidation_request_validation():
|
||||
"""Test ConsolidationRequest model validation."""
|
||||
# Valid request
|
||||
request = ConsolidationRequest(
|
||||
process_limit=10,
|
||||
lookback_days=7,
|
||||
min_web_results=2,
|
||||
dry_run=False
|
||||
)
|
||||
assert request.process_limit == 10
|
||||
assert request.lookback_days == 7
|
||||
assert request.min_web_results == 2
|
||||
assert request.dry_run is False
|
||||
|
||||
# Default values
|
||||
request = ConsolidationRequest()
|
||||
assert request.process_limit == 10
|
||||
assert request.lookback_days == 7
|
||||
assert request.min_web_results == 2
|
||||
assert request.dry_run is False
|
||||
|
||||
# Validate limits
|
||||
with pytest.raises(Exception):
|
||||
ConsolidationRequest(process_limit=0) # Too low
|
||||
|
||||
with pytest.raises(Exception):
|
||||
ConsolidationRequest(process_limit=101) # Too high
|
||||
|
||||
|
||||
def test_consolidation_response_model():
|
||||
"""Test ConsolidationResponse model."""
|
||||
response = ConsolidationResponse(
|
||||
total_found=5,
|
||||
processed_count=4,
|
||||
pages_created=2,
|
||||
pages_updated=3,
|
||||
entities_added=5,
|
||||
errors=["Error 1"],
|
||||
results=[],
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
assert response.total_found == 5
|
||||
assert response.processed_count == 4
|
||||
assert response.pages_created == 2
|
||||
assert len(response.errors) == 1
|
||||
|
||||
|
||||
def test_consolidation_result_model():
|
||||
"""Test ConsolidationResult model."""
|
||||
result = ConsolidationResult(
|
||||
search_id="test-123",
|
||||
query="test query",
|
||||
pages_created=1,
|
||||
pages_updated=2,
|
||||
entities_added=3,
|
||||
error=None
|
||||
)
|
||||
|
||||
assert result.search_id == "test-123"
|
||||
assert result.query == "test query"
|
||||
assert result.pages_created == 1
|
||||
assert result.error is None
|
||||
|
||||
|
||||
# Service Unit Tests
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_unprocessed_searches_empty(consolidation_service, mock_neo4j):
|
||||
"""Test finding unprocessed searches when none exist."""
|
||||
# Mock empty result
|
||||
mock_neo4j.execute_query.return_value = []
|
||||
|
||||
searches = await consolidation_service._find_unprocessed_searches(
|
||||
lookback_days=7,
|
||||
limit=10
|
||||
)
|
||||
|
||||
assert len(searches) == 0
|
||||
mock_neo4j.execute_query.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_unprocessed_searches_with_results(
|
||||
consolidation_service,
|
||||
mock_neo4j,
|
||||
sample_unprocessed_searches
|
||||
):
|
||||
"""Test finding unprocessed searches with results."""
|
||||
# Mock Neo4j response
|
||||
mock_neo4j.execute_query.return_value = sample_unprocessed_searches
|
||||
|
||||
searches = await consolidation_service._find_unprocessed_searches(
|
||||
lookback_days=7,
|
||||
limit=10
|
||||
)
|
||||
|
||||
assert len(searches) == 2
|
||||
assert searches[0]['query'] == 'docker orchestration kubernetes'
|
||||
assert searches[1]['query'] == 'python async programming'
|
||||
mock_neo4j.execute_query.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_web_results(consolidation_service, mock_neo4j, sample_web_results):
|
||||
"""Test retrieving web results for a search."""
|
||||
# Mock Neo4j response
|
||||
mock_neo4j.execute_query.return_value = sample_web_results
|
||||
|
||||
results = await consolidation_service._get_web_results(TEST_SEARCH_ID)
|
||||
|
||||
assert len(results) == 3
|
||||
assert results[0]['title'] == 'Kubernetes Documentation'
|
||||
assert results[1]['url'] == 'https://docs.docker.com/swarm'
|
||||
mock_neo4j.execute_query.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_web_results_empty(consolidation_service, mock_neo4j):
|
||||
"""Test retrieving web results when none exist."""
|
||||
mock_neo4j.execute_query.return_value = []
|
||||
|
||||
results = await consolidation_service._get_web_results(TEST_SEARCH_ID)
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_web_results_with_novel_info(
|
||||
consolidation_service,
|
||||
mock_ollama,
|
||||
mock_wiki,
|
||||
sample_web_results,
|
||||
sample_llm_analysis
|
||||
):
|
||||
"""Test analyzing web results with Ollama - novel info found."""
|
||||
# Mock Ollama response
|
||||
mock_ollama.generate_text.return_value = json.dumps(sample_llm_analysis)
|
||||
|
||||
analysis = await consolidation_service._analyze_web_results(
|
||||
query="docker orchestration",
|
||||
web_results=sample_web_results,
|
||||
keywords=["docker", "orchestration"],
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
assert analysis is not None
|
||||
assert analysis['has_novel_info'] is True
|
||||
assert len(analysis['new_pages']) == 1
|
||||
assert len(analysis['update_pages']) == 1
|
||||
assert len(analysis['new_entities']) == 2
|
||||
mock_ollama.generate_text.assert_called_once()
|
||||
# Verify taxonomy was fetched
|
||||
mock_wiki.get_taxonomy_structure.assert_called_once_with(f"users/{TEST_USER}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_web_results_no_novel_info(
|
||||
consolidation_service,
|
||||
mock_ollama,
|
||||
sample_web_results
|
||||
):
|
||||
"""Test analyzing web results - no novel info."""
|
||||
# Mock Ollama response with no novel info
|
||||
analysis_no_novel = {
|
||||
"has_novel_info": False,
|
||||
"new_pages": [],
|
||||
"update_pages": [],
|
||||
"new_entities": []
|
||||
}
|
||||
mock_ollama.generate_text.return_value = json.dumps(analysis_no_novel)
|
||||
|
||||
analysis = await consolidation_service._analyze_web_results(
|
||||
query="common topic",
|
||||
web_results=sample_web_results,
|
||||
keywords=[],
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
assert analysis is not None
|
||||
assert analysis['has_novel_info'] is False
|
||||
assert len(analysis['new_pages']) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_web_results_invalid_json(
|
||||
consolidation_service,
|
||||
mock_ollama,
|
||||
sample_web_results
|
||||
):
|
||||
"""Test analyzing web results with invalid JSON response."""
|
||||
# Mock Ollama response with invalid JSON
|
||||
mock_ollama.generate_text.return_value = "This is not JSON"
|
||||
|
||||
analysis = await consolidation_service._analyze_web_results(
|
||||
query="test query",
|
||||
web_results=sample_web_results,
|
||||
keywords=[],
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
assert analysis is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_web_results_json_in_markdown(
|
||||
consolidation_service,
|
||||
mock_ollama,
|
||||
sample_web_results,
|
||||
sample_llm_analysis
|
||||
):
|
||||
"""Test extracting JSON from markdown-wrapped response."""
|
||||
# Mock Ollama response with JSON wrapped in markdown
|
||||
wrapped_response = f"""Here's the analysis:
|
||||
|
||||
```json
|
||||
{json.dumps(sample_llm_analysis)}
|
||||
```
|
||||
|
||||
Hope this helps!"""
|
||||
mock_ollama.generate_text.return_value = wrapped_response
|
||||
|
||||
analysis = await consolidation_service._analyze_web_results(
|
||||
query="test",
|
||||
web_results=sample_web_results,
|
||||
keywords=[],
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
assert analysis is not None
|
||||
assert analysis['has_novel_info'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_web_results_taxonomy_failure(
|
||||
consolidation_service,
|
||||
mock_ollama,
|
||||
mock_wiki,
|
||||
sample_web_results,
|
||||
sample_llm_analysis
|
||||
):
|
||||
"""Test that analysis continues even if taxonomy fetch fails."""
|
||||
# Mock taxonomy fetch failure
|
||||
mock_wiki.get_taxonomy_structure.side_effect = Exception("Connection error")
|
||||
mock_ollama.generate_text.return_value = json.dumps(sample_llm_analysis)
|
||||
|
||||
analysis = await consolidation_service._analyze_web_results(
|
||||
query="test",
|
||||
web_results=sample_web_results,
|
||||
keywords=[],
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
# Should still succeed, just without taxonomy info in prompt
|
||||
assert analysis is not None
|
||||
assert analysis['has_novel_info'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_taxonomy_for_prompt(consolidation_service):
|
||||
"""Test taxonomy formatting for LLM prompt."""
|
||||
taxonomy = {
|
||||
"companies": [],
|
||||
"places": ["the-netherlands", "rotterdam"],
|
||||
"reference": ["political-entities"]
|
||||
}
|
||||
|
||||
formatted = consolidation_service._format_taxonomy_for_prompt(taxonomy)
|
||||
|
||||
assert "Existing paths" in formatted
|
||||
assert "companies/" in formatted
|
||||
assert "places/the-netherlands/" in formatted
|
||||
assert "places/rotterdam/" in formatted
|
||||
assert "reference/political-entities/" in formatted
|
||||
assert "IMPORTANT" in formatted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_taxonomy_empty(consolidation_service):
|
||||
"""Test taxonomy formatting with empty taxonomy."""
|
||||
formatted = consolidation_service._format_taxonomy_for_prompt({})
|
||||
assert formatted == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_search_processed(consolidation_service, mock_neo4j):
|
||||
"""Test marking search as processed."""
|
||||
await consolidation_service._mark_search_processed(TEST_SEARCH_ID)
|
||||
|
||||
mock_neo4j.execute_query.assert_called_once()
|
||||
call_args = mock_neo4j.execute_query.call_args
|
||||
assert TEST_SEARCH_ID in str(call_args)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_search_insufficient_web_results(
|
||||
consolidation_service,
|
||||
sample_unprocessed_searches
|
||||
):
|
||||
"""Test processing search with insufficient web results."""
|
||||
search = sample_unprocessed_searches[1].copy()
|
||||
search['web_count'] = 1 # Below minimum
|
||||
|
||||
result = await consolidation_service._process_search(
|
||||
search=search,
|
||||
min_web_results=2,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_search_no_web_results_in_db(
|
||||
consolidation_service,
|
||||
mock_neo4j,
|
||||
sample_unprocessed_searches
|
||||
):
|
||||
"""Test processing search when web results not found in DB."""
|
||||
mock_neo4j.execute_query.return_value = []
|
||||
|
||||
result = await consolidation_service._process_search(
|
||||
search=sample_unprocessed_searches[0],
|
||||
min_web_results=2,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_search_dry_run(
|
||||
consolidation_service,
|
||||
mock_neo4j,
|
||||
mock_ollama,
|
||||
sample_unprocessed_searches,
|
||||
sample_web_results,
|
||||
sample_llm_analysis
|
||||
):
|
||||
"""Test processing search in dry run mode."""
|
||||
# Mock responses
|
||||
mock_neo4j.execute_query.return_value = sample_web_results
|
||||
mock_ollama.generate_text.return_value = json.dumps(sample_llm_analysis)
|
||||
|
||||
result = await consolidation_service._process_search(
|
||||
search=sample_unprocessed_searches[0],
|
||||
min_web_results=2,
|
||||
dry_run=True
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.search_id == 'search-1'
|
||||
assert result.pages_created == 1
|
||||
assert result.pages_updated == 1
|
||||
assert result.entities_added == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidate_knowledge_no_searches(
|
||||
consolidation_service,
|
||||
mock_neo4j
|
||||
):
|
||||
"""Test consolidation when no unprocessed searches found."""
|
||||
mock_neo4j.execute_query.return_value = []
|
||||
|
||||
response = await consolidation_service.consolidate_knowledge(
|
||||
process_limit=10,
|
||||
lookback_days=7,
|
||||
min_web_results=2,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
assert response.total_found == 0
|
||||
assert response.processed_count == 0
|
||||
assert response.pages_created == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidate_knowledge_success(
|
||||
consolidation_service,
|
||||
mock_neo4j,
|
||||
mock_ollama,
|
||||
mock_wiki,
|
||||
sample_unprocessed_searches,
|
||||
sample_web_results,
|
||||
sample_llm_analysis
|
||||
):
|
||||
"""Test successful knowledge consolidation."""
|
||||
# Mock finding searches and entity creation
|
||||
# Each search processes: get web results, add 2 entities, mark processed
|
||||
mock_neo4j.execute_query.side_effect = [
|
||||
sample_unprocessed_searches, # Find searches
|
||||
sample_web_results, # Get web results for search 1
|
||||
None, # Add entity 1 (Kubernetes)
|
||||
None, # Add entity 2 (Docker Swarm)
|
||||
None, # Mark search 1 processed
|
||||
sample_web_results, # Get web results for search 2
|
||||
None, # Add entity 1 (Kubernetes)
|
||||
None, # Add entity 2 (Docker Swarm)
|
||||
None, # Mark search 2 processed
|
||||
]
|
||||
|
||||
# Mock wiki operations
|
||||
mock_wiki.search_pages.return_value = [] # No existing pages
|
||||
mock_wiki.create_page.return_value = None
|
||||
mock_wiki.update_page.return_value = None
|
||||
mock_wiki.get_page.return_value = None
|
||||
|
||||
# Mock LLM analysis and WikiPageWriter LLM calls
|
||||
mock_ollama.generate_text.return_value = json.dumps(sample_llm_analysis)
|
||||
|
||||
response = await consolidation_service.consolidate_knowledge(
|
||||
process_limit=10,
|
||||
lookback_days=7,
|
||||
min_web_results=2,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
assert response.total_found == 2
|
||||
assert response.processed_count == 2
|
||||
assert response.dry_run is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidate_knowledge_with_errors(
|
||||
consolidation_service,
|
||||
mock_neo4j,
|
||||
mock_ollama,
|
||||
sample_unprocessed_searches
|
||||
):
|
||||
"""Test consolidation with some searches failing."""
|
||||
# Mock finding searches - return empty for web results to trigger internal error handling
|
||||
mock_neo4j.execute_query.side_effect = [
|
||||
sample_unprocessed_searches, # Find searches
|
||||
[], # Empty web results for search 1 (causes skip, not error)
|
||||
[], # Empty web results for search 2 (causes skip, not error)
|
||||
]
|
||||
|
||||
response = await consolidation_service.consolidate_knowledge(
|
||||
process_limit=10,
|
||||
lookback_days=7,
|
||||
min_web_results=2,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
assert response.total_found == 2
|
||||
# Both searches skipped due to no web results (not errors)
|
||||
assert response.processed_count == 0
|
||||
|
||||
|
||||
# Integration Tests (API Endpoint)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_endpoint_minimal_request(consolidation_service):
|
||||
"""Test consolidation endpoint with minimal request."""
|
||||
pytest.importorskip("fastapi") # Skip if fastapi not available
|
||||
|
||||
# This would require proper test client setup
|
||||
# Placeholder for integration test structure
|
||||
request = ConsolidationRequest()
|
||||
assert request.process_limit == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_endpoint_custom_config(consolidation_service):
|
||||
"""Test consolidation endpoint with custom configuration."""
|
||||
request = ConsolidationRequest(
|
||||
process_limit=5,
|
||||
lookback_days=14,
|
||||
min_web_results=3,
|
||||
dry_run=True
|
||||
)
|
||||
|
||||
assert request.process_limit == 5
|
||||
assert request.lookback_days == 14
|
||||
assert request.min_web_results == 3
|
||||
assert request.dry_run is True
|
||||
|
||||
|
||||
# Edge Cases
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidate_with_max_limits(consolidation_service, mock_neo4j):
|
||||
"""Test consolidation with maximum limits."""
|
||||
mock_neo4j.execute_query.return_value = []
|
||||
|
||||
response = await consolidation_service.consolidate_knowledge(
|
||||
process_limit=100, # Max
|
||||
lookback_days=90, # Max
|
||||
min_web_results=20, # Max
|
||||
dry_run=True
|
||||
)
|
||||
|
||||
assert response.total_found == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_empty_web_results(consolidation_service, mock_ollama):
|
||||
"""Test analyzing with empty web results list."""
|
||||
mock_ollama.generate_text.return_value = json.dumps({
|
||||
"has_novel_info": False,
|
||||
"new_pages": [],
|
||||
"update_pages": [],
|
||||
"new_entities": []
|
||||
})
|
||||
|
||||
analysis = await consolidation_service._analyze_web_results(
|
||||
query="test",
|
||||
web_results=[],
|
||||
keywords=[],
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
# Should still call LLM but return no novel info
|
||||
assert analysis is not None
|
||||
|
||||
|
||||
# Performance/Load Tests (optional)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_many_searches_dry_run(
|
||||
consolidation_service,
|
||||
mock_neo4j,
|
||||
mock_ollama
|
||||
):
|
||||
"""Test processing many searches in dry run mode."""
|
||||
# Generate many test searches
|
||||
many_searches = [
|
||||
{
|
||||
'id': f'search-{i}',
|
||||
'query': f'test query {i}',
|
||||
'user': TEST_USER,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'total_results': 5,
|
||||
'web_count': 3,
|
||||
'keywords': ['test']
|
||||
}
|
||||
for i in range(50)
|
||||
]
|
||||
|
||||
mock_neo4j.execute_query.return_value = many_searches[:10] # Limit by config
|
||||
|
||||
response = await consolidation_service.consolidate_knowledge(
|
||||
process_limit=10,
|
||||
lookback_days=7,
|
||||
min_web_results=2,
|
||||
dry_run=True
|
||||
)
|
||||
|
||||
# Should only process up to limit
|
||||
assert response.total_found == 10
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
@@ -0,0 +1,484 @@
|
||||
"""
|
||||
Comprehensive tests for Entity Linking system.
|
||||
|
||||
Tests cover:
|
||||
- Finding entity mentions in pages
|
||||
- Creating MENTIONS relationships in Neo4j
|
||||
- Adding hyperlinks to wiki content
|
||||
- Idempotency (safe to run multiple times)
|
||||
- Protection of existing links (no nesting)
|
||||
- Multi-tenancy isolation
|
||||
|
||||
Run with: pytest tests/test_entity_linking.py -v -s
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.services.graph_service import GraphService
|
||||
from src.services.wiki_service import WikiService
|
||||
from src.routers.entity_linking import (
|
||||
find_entity_mentions,
|
||||
add_entity_links_to_content,
|
||||
get_entities_with_paths
|
||||
)
|
||||
from src.config import get_settings
|
||||
|
||||
# Test user to isolate test data
|
||||
TEST_USER = "entity-link-tester"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings():
|
||||
"""Get application settings."""
|
||||
return get_settings()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
|
||||
"""Get connected Neo4j client."""
|
||||
client = Neo4jClient(
|
||||
uri=settings.neo4j_uri,
|
||||
user=settings.neo4j_user,
|
||||
password=settings.neo4j_password
|
||||
)
|
||||
await client.connect()
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def wiki_client(settings) -> AsyncGenerator[WikiJSClient, None]:
|
||||
"""Get Wiki.js client."""
|
||||
client = WikiJSClient(
|
||||
base_url=settings.wikijs_url,
|
||||
username=settings.wikijs_username,
|
||||
password=settings.wikijs_password
|
||||
)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def graph_service(neo4j_client, wiki_client):
|
||||
"""Get GraphService instance."""
|
||||
return GraphService(neo4j_client, wiki_client)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def wiki_service(wiki_client):
|
||||
"""Get WikiService instance."""
|
||||
return WikiService(wiki_client)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_entities(graph_service):
|
||||
"""Create test entities in graph."""
|
||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||
|
||||
user_base_label = get_neo4j_user_base_label(TEST_USER)
|
||||
|
||||
# Clean up any existing test entities
|
||||
cleanup_query = f"""
|
||||
MATCH (n:{user_base_label})
|
||||
WHERE n.name IN ['Docker', 'Kubernetes', 'PostgreSQL']
|
||||
DETACH DELETE n
|
||||
"""
|
||||
await graph_service.neo4j.execute_query(cleanup_query)
|
||||
|
||||
# Create test entities
|
||||
create_query = f"""
|
||||
CREATE (d:{user_base_label}:Technology {{name: 'Docker', type: 'technology'}})
|
||||
CREATE (k:{user_base_label}:Technology {{name: 'Kubernetes', type: 'technology'}})
|
||||
CREATE (p:{user_base_label}:Technology {{name: 'PostgreSQL', type: 'technology'}})
|
||||
RETURN d.name, k.name, p.name
|
||||
"""
|
||||
await graph_service.neo4j.execute_query(create_query)
|
||||
|
||||
yield ["Docker", "Kubernetes", "PostgreSQL"]
|
||||
|
||||
# Cleanup after test
|
||||
await graph_service.neo4j.execute_query(cleanup_query)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests - Entity Mention Detection
|
||||
# ============================================================================
|
||||
|
||||
class TestFindEntityMentions:
|
||||
"""Test finding entity mentions in content."""
|
||||
|
||||
def test_find_single_mention(self):
|
||||
"""Test finding a single entity mention."""
|
||||
content = "Docker is a containerization platform."
|
||||
entities = [
|
||||
{"name": "Docker", "type": "technology"}
|
||||
]
|
||||
|
||||
found = find_entity_mentions(content, entities)
|
||||
|
||||
assert len(found) == 1
|
||||
assert found[0]["name"] == "Docker"
|
||||
assert found[0]["mentions"] == 1
|
||||
|
||||
def test_find_multiple_mentions(self):
|
||||
"""Test finding multiple mentions of same entity."""
|
||||
content = "Docker containers run on Docker Engine. Docker is great!"
|
||||
entities = [
|
||||
{"name": "Docker", "type": "technology"}
|
||||
]
|
||||
|
||||
found = find_entity_mentions(content, entities)
|
||||
|
||||
assert len(found) == 1
|
||||
assert found[0]["name"] == "Docker"
|
||||
assert found[0]["mentions"] == 3
|
||||
|
||||
def test_case_insensitive_matching(self):
|
||||
"""Test case-insensitive entity matching."""
|
||||
content = "docker and DOCKER and Docker are the same"
|
||||
entities = [
|
||||
{"name": "Docker", "type": "technology"}
|
||||
]
|
||||
|
||||
found = find_entity_mentions(content, entities)
|
||||
|
||||
assert len(found) == 1
|
||||
assert found[0]["mentions"] == 3
|
||||
|
||||
def test_whole_word_matching(self):
|
||||
"""Test that partial word matches are excluded."""
|
||||
content = "Kubernetes and Kubernetes-based and MyKubernetes"
|
||||
entities = [
|
||||
{"name": "Kubernetes", "type": "technology"}
|
||||
]
|
||||
|
||||
found = find_entity_mentions(content, entities)
|
||||
|
||||
assert len(found) == 1
|
||||
# Regex \b matches at hyphens, so "Kubernetes-based" contains "Kubernetes"
|
||||
# Only "MyKubernetes" is excluded (no word boundary)
|
||||
assert found[0]["mentions"] == 2 # "Kubernetes" and "Kubernetes-based"
|
||||
|
||||
def test_ignore_short_names(self):
|
||||
"""Test that entities with names <3 chars are ignored."""
|
||||
content = "Go is a programming language by Google"
|
||||
entities = [
|
||||
{"name": "Go", "type": "language"}, # Too short
|
||||
{"name": "Google", "type": "organization"}
|
||||
]
|
||||
|
||||
found = find_entity_mentions(content, entities)
|
||||
|
||||
assert len(found) == 1
|
||||
assert found[0]["name"] == "Google"
|
||||
|
||||
def test_sort_by_mention_count(self):
|
||||
"""Test results are sorted by mention count."""
|
||||
content = "Docker Docker Docker. Kubernetes Kubernetes. PostgreSQL."
|
||||
entities = [
|
||||
{"name": "PostgreSQL", "type": "database"},
|
||||
{"name": "Docker", "type": "technology"},
|
||||
{"name": "Kubernetes", "type": "technology"}
|
||||
]
|
||||
|
||||
found = find_entity_mentions(content, entities)
|
||||
|
||||
assert len(found) == 3
|
||||
assert found[0]["name"] == "Docker" # Most mentions
|
||||
assert found[0]["mentions"] == 3
|
||||
assert found[1]["name"] == "Kubernetes"
|
||||
assert found[1]["mentions"] == 2
|
||||
assert found[2]["name"] == "PostgreSQL"
|
||||
assert found[2]["mentions"] == 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests - Content Link Addition
|
||||
# ============================================================================
|
||||
|
||||
class TestAddEntityLinksToContent:
|
||||
"""Test adding hyperlinks to content."""
|
||||
|
||||
def test_add_single_link(self):
|
||||
"""Test adding a single entity link."""
|
||||
content = "Docker is a containerization platform."
|
||||
entities = [
|
||||
{"name": "Docker", "path": "users/test/docker"}
|
||||
]
|
||||
|
||||
updated, count = add_entity_links_to_content(content, entities)
|
||||
|
||||
assert count == 1
|
||||
assert "[Docker](/docker)" in updated
|
||||
|
||||
def test_add_multiple_instances(self):
|
||||
"""Test linking all instances of an entity."""
|
||||
content = "Docker containers run on Docker Engine."
|
||||
entities = [
|
||||
{"name": "Docker", "path": "users/test/docker"}
|
||||
]
|
||||
|
||||
updated, count = add_entity_links_to_content(content, entities)
|
||||
|
||||
assert count == 2 # Both instances linked
|
||||
assert updated.count("[Docker](/docker)") == 2
|
||||
|
||||
def test_skip_entities_without_path(self):
|
||||
"""Test that entities without wiki pages are not linked."""
|
||||
content = "Docker and Kubernetes are used together."
|
||||
entities = [
|
||||
{"name": "Docker", "path": "users/test/docker"},
|
||||
{"name": "Kubernetes", "path": None} # No page
|
||||
]
|
||||
|
||||
updated, count = add_entity_links_to_content(content, entities)
|
||||
|
||||
assert count == 1 # Only Docker
|
||||
assert "[Docker](/docker)" in updated
|
||||
assert "[Kubernetes]" not in updated
|
||||
|
||||
def test_protect_existing_links(self):
|
||||
"""Test that existing markdown links are not modified."""
|
||||
content = "See [Docker](https://docker.com) for more info. Docker is great!"
|
||||
entities = [
|
||||
{"name": "Docker", "path": "users/test/docker"}
|
||||
]
|
||||
|
||||
updated, count = add_entity_links_to_content(content, entities)
|
||||
|
||||
# Should link the second "Docker" but not the one already linked
|
||||
assert count == 1
|
||||
assert "[Docker](https://docker.com)" in updated # Preserved
|
||||
assert updated.count("[Docker](/docker)") == 1
|
||||
|
||||
def test_no_nested_links(self):
|
||||
"""Test that entity names in URLs are not linked."""
|
||||
content = "Check [Docker Hub](/docker/hub) for images."
|
||||
entities = [
|
||||
{"name": "Docker", "path": "users/test/docker"}
|
||||
]
|
||||
|
||||
updated, count = add_entity_links_to_content(content, entities)
|
||||
|
||||
# "Docker" in the URL path should not be linked
|
||||
assert count == 0
|
||||
assert "[Docker Hub](/docker/hub)" in updated # Unchanged
|
||||
|
||||
def test_longest_first_matching(self):
|
||||
"""Test that longer entity names are matched first."""
|
||||
content = "Machine Learning and Machine are different."
|
||||
entities = [
|
||||
{"name": "Machine Learning", "path": "users/test/ml"},
|
||||
{"name": "Machine", "path": "users/test/machine"}
|
||||
]
|
||||
|
||||
updated, count = add_entity_links_to_content(content, entities)
|
||||
|
||||
# Should link "Machine Learning" first, leaving "Machine" alone
|
||||
assert "[Machine Learning](/ml)" in updated
|
||||
assert count >= 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests - Full Entity Linking Flow
|
||||
# ============================================================================
|
||||
|
||||
class TestEntityLinkingIntegration:
|
||||
"""Test full entity linking flow."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_entities_with_paths(self, graph_service, test_entities):
|
||||
"""Test retrieving entities and their wiki page paths."""
|
||||
entities = await get_entities_with_paths(graph_service, TEST_USER)
|
||||
|
||||
# Should find our test entities
|
||||
entity_names = [e["name"] for e in entities]
|
||||
assert "Docker" in entity_names
|
||||
assert "Kubernetes" in entity_names
|
||||
assert "PostgreSQL" in entity_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mentions_relationships(self, graph_service, test_entities):
|
||||
"""Test creating MENTIONS relationships."""
|
||||
from src.core.multi_tenancy import get_neo4j_user_label
|
||||
|
||||
user_doc_label = get_neo4j_user_label(TEST_USER)
|
||||
|
||||
# Create a test document node
|
||||
doc_query = f"""
|
||||
CREATE (d:{user_doc_label}:Document {{
|
||||
page_id: 9999,
|
||||
title: 'Test Doc',
|
||||
path: 'users/test/doc'
|
||||
}})
|
||||
RETURN d
|
||||
"""
|
||||
await graph_service.neo4j.execute_query(doc_query)
|
||||
|
||||
# Create MENTIONS relationships
|
||||
found_entities = [
|
||||
{"name": "Docker"},
|
||||
{"name": "Kubernetes"}
|
||||
]
|
||||
|
||||
new_links = await graph_service.create_entity_mentions(
|
||||
page_id=9999,
|
||||
user=TEST_USER,
|
||||
entity_names=found_entities
|
||||
)
|
||||
|
||||
assert new_links == 2
|
||||
|
||||
# Verify relationships exist
|
||||
verify_query = f"""
|
||||
MATCH (d:{user_doc_label}:Document {{page_id: 9999}})-[r:MENTIONS]->(e)
|
||||
RETURN count(r) as mention_count
|
||||
"""
|
||||
result = await graph_service.neo4j.execute_query(verify_query)
|
||||
assert result[0]["mention_count"] == 2
|
||||
|
||||
# Cleanup
|
||||
cleanup_query = f"""
|
||||
MATCH (d:{user_doc_label}:Document {{page_id: 9999}})
|
||||
DETACH DELETE d
|
||||
"""
|
||||
await graph_service.neo4j.execute_query(cleanup_query)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotency(self, graph_service):
|
||||
"""Test that entity linking is idempotent."""
|
||||
from src.core.multi_tenancy import get_neo4j_user_label, get_neo4j_user_base_label
|
||||
|
||||
user_doc_label = get_neo4j_user_label(TEST_USER)
|
||||
user_base_label = get_neo4j_user_base_label(TEST_USER)
|
||||
|
||||
# Aggressively clean up ALL test data first (fresh start)
|
||||
cleanup_all = f"""
|
||||
MATCH (n)
|
||||
WHERE (n:{user_base_label} OR n:{user_doc_label})
|
||||
AND (n.page_id = 9998 OR n.name = 'TestDockerEntity')
|
||||
DETACH DELETE n
|
||||
"""
|
||||
await graph_service.neo4j.execute_query(cleanup_all)
|
||||
|
||||
# Create a unique test entity
|
||||
entity_query = f"""
|
||||
CREATE (e:{user_base_label}:Technology {{name: 'TestDockerEntity', type: 'technology'}})
|
||||
RETURN e
|
||||
"""
|
||||
await graph_service.neo4j.execute_query(entity_query)
|
||||
|
||||
# Create test document
|
||||
doc_query = f"""
|
||||
CREATE (d:{user_doc_label}:Document {{
|
||||
page_id: 9998,
|
||||
title: 'Test Doc 2',
|
||||
path: 'users/test/doc2'
|
||||
}})
|
||||
RETURN d
|
||||
"""
|
||||
await graph_service.neo4j.execute_query(doc_query)
|
||||
|
||||
found_entities = [{"name": "TestDockerEntity"}]
|
||||
|
||||
# Link once
|
||||
first_run = await graph_service.create_entity_mentions(
|
||||
page_id=9998,
|
||||
user=TEST_USER,
|
||||
entity_names=found_entities
|
||||
)
|
||||
assert first_run == 1
|
||||
|
||||
# Link again - should not create duplicates
|
||||
second_run = await graph_service.create_entity_mentions(
|
||||
page_id=9998,
|
||||
user=TEST_USER,
|
||||
entity_names=found_entities
|
||||
)
|
||||
assert second_run == 0 # No new links
|
||||
|
||||
# Verify only one relationship exists
|
||||
verify_query = f"""
|
||||
MATCH (d:{user_doc_label}:Document {{page_id: 9998}})-[r:MENTIONS]->()
|
||||
RETURN count(r) as mention_count
|
||||
"""
|
||||
result = await graph_service.neo4j.execute_query(verify_query)
|
||||
assert result[0]["mention_count"] == 1
|
||||
|
||||
# Cleanup
|
||||
cleanup_query = f"""
|
||||
MATCH (n)
|
||||
WHERE (n:{user_base_label} OR n:{user_doc_label})
|
||||
AND (n.page_id = 9998 OR n.name = 'TestDockerEntity')
|
||||
DETACH DELETE n
|
||||
"""
|
||||
await graph_service.neo4j.execute_query(cleanup_query)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Multi-Tenancy Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestEntityLinkingMultiTenancy:
|
||||
"""Test multi-tenancy isolation in entity linking."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_isolation(self, graph_service):
|
||||
"""Test that entities are isolated by user."""
|
||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||
|
||||
user1_label = get_neo4j_user_base_label("user1")
|
||||
user2_label = get_neo4j_user_base_label("user2")
|
||||
|
||||
# Create entity for user1
|
||||
create_user1 = f"""
|
||||
CREATE (e:{user1_label}:Technology {{name: 'Docker', type: 'technology'}})
|
||||
RETURN e
|
||||
"""
|
||||
await graph_service.neo4j.execute_query(create_user1)
|
||||
|
||||
# Create entity for user2
|
||||
create_user2 = f"""
|
||||
CREATE (e:{user2_label}:Technology {{name: 'Docker', type: 'technology'}})
|
||||
RETURN e
|
||||
"""
|
||||
await graph_service.neo4j.execute_query(create_user2)
|
||||
|
||||
# Get entities for user1 - should only see user1's entities
|
||||
entities_user1 = await get_entities_with_paths(graph_service, "user1")
|
||||
entity_names_user1 = [e["name"] for e in entities_user1]
|
||||
|
||||
# Verify isolation
|
||||
assert "Docker" in entity_names_user1
|
||||
# We can't verify the exact count without knowing what else is in the DB,
|
||||
# but we verified we can retrieve entities for user1
|
||||
|
||||
# Cleanup
|
||||
await graph_service.neo4j.execute_query(f"MATCH (e:{user1_label}) WHERE e.name = 'Docker' DETACH DELETE e")
|
||||
await graph_service.neo4j.execute_query(f"MATCH (e:{user2_label}) WHERE e.name = 'Docker' DETACH DELETE e")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Cleanup
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_entity_linking_test_data(neo4j_client):
|
||||
"""Clean up all test data created by entity linking tests."""
|
||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||
|
||||
for user in [TEST_USER, "user1", "user2"]:
|
||||
user_label = get_neo4j_user_base_label(user)
|
||||
|
||||
cleanup_query = f"""
|
||||
MATCH (n:{user_label})
|
||||
WHERE n.page_id IN [9999, 9998]
|
||||
OR n.name IN ['Docker', 'Kubernetes', 'PostgreSQL']
|
||||
DETACH DELETE n
|
||||
"""
|
||||
await neo4j_client.execute_query(cleanup_query)
|
||||
|
||||
print(f"\n✓ Cleaned up entity linking test data")
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Tests for GraphService - knowledge graph operations.
|
||||
|
||||
Tests cover:
|
||||
- Document node creation with tags
|
||||
- Entity-stub page skipping
|
||||
- Entity extraction
|
||||
|
||||
Run with: pytest tests/test_graph_service.py -v -s
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from src.services.graph_service import GraphService
|
||||
|
||||
|
||||
# Test constants
|
||||
TEST_USER = "graph-tester"
|
||||
TEST_PAGE_ID = 123
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_neo4j():
|
||||
"""Mock Neo4j client."""
|
||||
mock = AsyncMock()
|
||||
mock.execute_query = AsyncMock(return_value=[{"d": {"page_id": TEST_PAGE_ID}}])
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_wiki():
|
||||
"""Mock Wiki.js client."""
|
||||
mock = AsyncMock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graph_service(mock_neo4j, mock_wiki):
|
||||
"""Get GraphService with mocked dependencies."""
|
||||
return GraphService(
|
||||
neo4j_client=mock_neo4j,
|
||||
wikijs_client=mock_wiki
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_page():
|
||||
"""Sample wiki page data."""
|
||||
return {
|
||||
"id": TEST_PAGE_ID,
|
||||
"title": "Test Page",
|
||||
"path": f"users/{TEST_USER}/technology/docker",
|
||||
"content": "Docker is a containerization platform. It uses containers to run applications.",
|
||||
"tags": ["technology", "docker", "containers"]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_page_without_tags():
|
||||
"""Sample wiki page without tags."""
|
||||
return {
|
||||
"id": TEST_PAGE_ID,
|
||||
"title": "Test Page No Tags",
|
||||
"path": f"users/{TEST_USER}/misc/test",
|
||||
"content": "This is a test page with no tags.",
|
||||
"tags": []
|
||||
}
|
||||
|
||||
|
||||
class TestDocumentNodeCreation:
|
||||
"""Test Document node creation in Neo4j."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_node_includes_tags(
|
||||
self,
|
||||
graph_service,
|
||||
mock_neo4j,
|
||||
mock_wiki,
|
||||
sample_page
|
||||
):
|
||||
"""Test that Document node is created with tags property."""
|
||||
mock_wiki.get_page = AsyncMock(return_value=sample_page)
|
||||
|
||||
result = await graph_service.update_from_page(
|
||||
page_id=TEST_PAGE_ID,
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
# Verify execute_query was called
|
||||
assert mock_neo4j.execute_query.called
|
||||
assert result.success is True
|
||||
|
||||
# Find the document creation query
|
||||
calls = mock_neo4j.execute_query.call_args_list
|
||||
doc_creation_call = None
|
||||
for call in calls:
|
||||
query = call[0][0] if call[0] else ""
|
||||
if "MERGE" in query and "Document" in query and "tags" in query:
|
||||
doc_creation_call = call
|
||||
break
|
||||
|
||||
assert doc_creation_call is not None, "Document creation query with tags not found"
|
||||
|
||||
# Verify tags are in the query parameters
|
||||
params = doc_creation_call[0][1] if len(doc_creation_call[0]) > 1 else {}
|
||||
assert "tags" in params
|
||||
assert params["tags"] == ["technology", "docker", "containers"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_node_with_empty_tags(
|
||||
self,
|
||||
graph_service,
|
||||
mock_neo4j,
|
||||
mock_wiki,
|
||||
sample_page_without_tags
|
||||
):
|
||||
"""Test Document node creation with empty tags list."""
|
||||
mock_wiki.get_page = AsyncMock(return_value=sample_page_without_tags)
|
||||
|
||||
result = await graph_service.update_from_page(
|
||||
page_id=TEST_PAGE_ID,
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
# Find the document creation query
|
||||
calls = mock_neo4j.execute_query.call_args_list
|
||||
doc_creation_call = None
|
||||
for call in calls:
|
||||
query = call[0][0] if call[0] else ""
|
||||
if "MERGE" in query and "Document" in query:
|
||||
doc_creation_call = call
|
||||
break
|
||||
|
||||
assert doc_creation_call is not None
|
||||
params = doc_creation_call[0][1] if len(doc_creation_call[0]) > 1 else {}
|
||||
assert "tags" in params
|
||||
assert params["tags"] == []
|
||||
|
||||
|
||||
class TestEntityStubSkipping:
|
||||
"""Test that entity-stub pages are skipped."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_entity_stub_pages(
|
||||
self,
|
||||
graph_service,
|
||||
mock_neo4j,
|
||||
mock_wiki
|
||||
):
|
||||
"""Test that entity-stub tagged pages skip entity extraction."""
|
||||
stub_page = {
|
||||
"id": TEST_PAGE_ID,
|
||||
"title": "Auto Entity",
|
||||
"path": f"users/{TEST_USER}/entities/test",
|
||||
"content": "Auto-generated content.",
|
||||
"tags": ["entity-stub", "auto-generated"]
|
||||
}
|
||||
mock_wiki.get_page = AsyncMock(return_value=stub_page)
|
||||
|
||||
result = await graph_service.update_from_page(
|
||||
page_id=TEST_PAGE_ID,
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
# Should return success but skip processing
|
||||
assert result.success is True
|
||||
# Neo4j should NOT be called for entity-stub pages
|
||||
assert mock_neo4j.execute_query.call_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_auto_generated_pages(
|
||||
self,
|
||||
graph_service,
|
||||
mock_neo4j,
|
||||
mock_wiki
|
||||
):
|
||||
"""Test that auto-generated tagged pages skip entity extraction."""
|
||||
auto_page = {
|
||||
"id": TEST_PAGE_ID,
|
||||
"title": "Auto Page",
|
||||
"path": f"users/{TEST_USER}/auto/test",
|
||||
"content": "Auto-generated content.",
|
||||
"tags": ["auto-generated"]
|
||||
}
|
||||
mock_wiki.get_page = AsyncMock(return_value=auto_page)
|
||||
|
||||
result = await graph_service.update_from_page(
|
||||
page_id=TEST_PAGE_ID,
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert mock_neo4j.execute_query.call_count == 0
|
||||
|
||||
|
||||
class TestPageNotFound:
|
||||
"""Test handling of missing pages."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_page_not_found_returns_failure(
|
||||
self,
|
||||
graph_service,
|
||||
mock_wiki
|
||||
):
|
||||
"""Test that missing page returns failure result."""
|
||||
mock_wiki.get_page = AsyncMock(return_value=None)
|
||||
|
||||
result = await graph_service.update_from_page(
|
||||
page_id=999,
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
# The service catches the exception and returns a failed result
|
||||
assert result.success is False
|
||||
assert result.error_message is not None
|
||||
assert "not found" in result.error_message.lower()
|
||||
|
||||
|
||||
class TestEntityExtraction:
|
||||
"""Test entity extraction from page content."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_document_and_entities(
|
||||
self,
|
||||
graph_service,
|
||||
mock_neo4j,
|
||||
mock_wiki,
|
||||
sample_page
|
||||
):
|
||||
"""Test that document and entity nodes are created."""
|
||||
mock_wiki.get_page = AsyncMock(return_value=sample_page)
|
||||
|
||||
result = await graph_service.update_from_page(
|
||||
page_id=TEST_PAGE_ID,
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
# Should have called neo4j at least once (for document node)
|
||||
assert mock_neo4j.execute_query.called
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
@@ -0,0 +1,711 @@
|
||||
"""
|
||||
Comprehensive tests for HybridRAG system.
|
||||
|
||||
Tests cover all 6 phases:
|
||||
- Phase 0: Query Enhancement (keyword/synonym extraction)
|
||||
- Phase 1: Parallel Retrieval (vector + graph + web)
|
||||
- Phase 2: RRF Fusion
|
||||
- Phase 3: Enrichment (related dossiers)
|
||||
- Phase 4: LLM Re-ranking
|
||||
- Phase 5: Context Formatting
|
||||
- Phase 6: Persistence (search storage)
|
||||
|
||||
Uses 'llm-tester' user to avoid contaminating production data.
|
||||
|
||||
Run with: pytest tests/test_hybrid_rag.py -v -s
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from typing import AsyncGenerator
|
||||
import json
|
||||
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
from src.clients.qdrant_client import QdrantClientWrapper
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.clients.searxng_client import SearXNGClient
|
||||
from src.clients.ollama_client import OllamaClient
|
||||
from src.services.hybrid_rag_service import HybridRAGService
|
||||
from src.services.vector_service import VectorService
|
||||
from src.services.graph_service import GraphService
|
||||
from src.models.hybrid_rag import HybridRAGConfig, HybridRAGRequest
|
||||
from src.config import get_settings
|
||||
|
||||
# Test user to isolate test data
|
||||
TEST_USER = "llm-tester"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings():
|
||||
"""Get application settings."""
|
||||
return get_settings()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
|
||||
"""Get connected Neo4j client."""
|
||||
client = Neo4jClient(
|
||||
uri=settings.neo4j_uri,
|
||||
user=settings.neo4j_user,
|
||||
password=settings.neo4j_password
|
||||
)
|
||||
await client.connect()
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qdrant_client(settings) -> QdrantClientWrapper:
|
||||
"""Get Qdrant client."""
|
||||
return QdrantClientWrapper(url=settings.qdrant_url)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def wiki_client(settings) -> AsyncGenerator[WikiJSClient, None]:
|
||||
"""Get Wiki.js client."""
|
||||
client = WikiJSClient(
|
||||
base_url=settings.wikijs_url,
|
||||
username=settings.wikijs_username,
|
||||
password=settings.wikijs_password
|
||||
)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def searxng_client(settings) -> SearXNGClient:
|
||||
"""Get SearXNG client."""
|
||||
return SearXNGClient(base_url=settings.searxng_url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ollama_client(settings) -> OllamaClient:
|
||||
"""Get Ollama client."""
|
||||
return OllamaClient(base_url=settings.ollama_url)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def vector_service(qdrant_client, wiki_client, ollama_client):
|
||||
"""Get VectorService instance."""
|
||||
return VectorService(qdrant_client, wiki_client, ollama_client)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def graph_service(neo4j_client, wiki_client):
|
||||
"""Get GraphService instance."""
|
||||
return GraphService(neo4j_client, wiki_client)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def hybrid_rag_service(
|
||||
vector_service,
|
||||
graph_service,
|
||||
searxng_client,
|
||||
ollama_client,
|
||||
settings
|
||||
):
|
||||
"""Get HybridRAGService instance."""
|
||||
return HybridRAGService(
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
searxng_client=searxng_client,
|
||||
ollama_client=ollama_client,
|
||||
settings=settings
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_wiki_page(wiki_client):
|
||||
"""
|
||||
Create test wiki page for llm-tester user.
|
||||
|
||||
Creates a page about Docker and Kubernetes for testing.
|
||||
"""
|
||||
from src.core.multi_tenancy import get_wikijs_namespace
|
||||
|
||||
namespace = get_wikijs_namespace(TEST_USER)
|
||||
path = f"{namespace}/testing/docker-kubernetes"
|
||||
|
||||
# Create test page
|
||||
page_data = {
|
||||
"title": "Docker and Kubernetes Testing",
|
||||
"path": path,
|
||||
"content": """# Docker and Kubernetes
|
||||
|
||||
Docker is a containerization platform that packages applications into containers.
|
||||
Kubernetes (k8s) is an orchestration platform for managing Docker containers at scale.
|
||||
|
||||
## Key Technologies
|
||||
- Docker: Container runtime
|
||||
- Kubernetes: Orchestration platform
|
||||
- Helm: Package manager for Kubernetes
|
||||
- kubectl: Command-line tool for k8s
|
||||
|
||||
## Use Cases
|
||||
Our infrastructure uses Docker containers orchestrated by Kubernetes clusters.
|
||||
We deploy microservices using Helm charts and manage them with kubectl.
|
||||
""",
|
||||
"description": "Test page for HybridRAG testing",
|
||||
"tags": ["testing", "infrastructure", "docker"]
|
||||
}
|
||||
|
||||
try:
|
||||
# Delete if exists
|
||||
existing = await wiki_client.search_pages(query="Docker and Kubernetes Testing")
|
||||
for page in existing:
|
||||
if page.get("path") == path:
|
||||
await wiki_client.delete_page(page["id"])
|
||||
|
||||
# Create new
|
||||
page = await wiki_client.create_page(**page_data)
|
||||
yield page
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
await wiki_client.delete_page(page["id"])
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
pytest.skip(f"Could not create test page: {e}")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_graph_data(graph_service, test_wiki_page):
|
||||
"""
|
||||
Populate graph with test data for llm-tester.
|
||||
|
||||
Extracts entities from test page.
|
||||
"""
|
||||
try:
|
||||
summary = await graph_service.update_from_page(
|
||||
page_id=test_wiki_page["id"],
|
||||
user=TEST_USER
|
||||
)
|
||||
yield summary
|
||||
except Exception as e:
|
||||
pytest.skip(f"Could not populate graph: {e}")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_vector_data(vector_service, test_wiki_page):
|
||||
"""
|
||||
Populate vector DB with test data for llm-tester.
|
||||
|
||||
Creates embeddings from test page.
|
||||
"""
|
||||
try:
|
||||
summary = await vector_service.update_from_page(
|
||||
page_id=test_wiki_page["id"],
|
||||
user=TEST_USER
|
||||
)
|
||||
yield summary
|
||||
except Exception as e:
|
||||
pytest.skip(f"Could not populate vectors: {e}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests - Individual Components
|
||||
# ============================================================================
|
||||
|
||||
class TestRRFFusion:
|
||||
"""Test Reciprocal Rank Fusion algorithm."""
|
||||
|
||||
def test_rrf_single_source(self, hybrid_rag_service):
|
||||
"""Test RRF with single source."""
|
||||
results_by_source = {
|
||||
"vector": [
|
||||
{"page_id": 1, "title": "Doc 1", "content": "test"},
|
||||
{"page_id": 2, "title": "Doc 2", "content": "test"}
|
||||
]
|
||||
}
|
||||
|
||||
fused = hybrid_rag_service._reciprocal_rank_fusion(results_by_source, k=60)
|
||||
|
||||
assert len(fused) == 2
|
||||
assert fused[0]["rrf_score"] > fused[1]["rrf_score"] # Rank 1 > Rank 2
|
||||
assert fused[0]["sources"] == ["vector"]
|
||||
|
||||
def test_rrf_multiple_sources_same_doc(self, hybrid_rag_service):
|
||||
"""Test RRF with same document from multiple sources."""
|
||||
results_by_source = {
|
||||
"vector": [{"page_id": 1, "title": "Doc 1", "content": "test"}],
|
||||
"graph": [{"page_id": 1, "title": "Doc 1", "content": ""}],
|
||||
}
|
||||
|
||||
fused = hybrid_rag_service._reciprocal_rank_fusion(results_by_source, k=60)
|
||||
|
||||
assert len(fused) == 1 # Deduplicated
|
||||
assert len(fused[0]["sources"]) == 2 # Both sources
|
||||
assert "vector" in fused[0]["sources"]
|
||||
assert "graph" in fused[0]["sources"]
|
||||
# RRF score should be sum: 1/(60+1) + 1/(60+1)
|
||||
expected_score = 1/61 + 1/61
|
||||
assert abs(fused[0]["rrf_score"] - expected_score) < 0.001
|
||||
|
||||
def test_rrf_web_results(self, hybrid_rag_service):
|
||||
"""Test RRF with web results (URL-based)."""
|
||||
results_by_source = {
|
||||
"web": [
|
||||
{"url": "https://example.com/1", "title": "Web 1", "content": "test"},
|
||||
{"url": "https://example.com/2", "title": "Web 2", "content": "test"}
|
||||
]
|
||||
}
|
||||
|
||||
fused = hybrid_rag_service._reciprocal_rank_fusion(results_by_source, k=60)
|
||||
|
||||
assert len(fused) == 2
|
||||
assert fused[0]["result"]["url"] == "https://example.com/1"
|
||||
|
||||
|
||||
class TestContextFormatting:
|
||||
"""Test context formatting for LLM."""
|
||||
|
||||
def test_format_basic(self, hybrid_rag_service):
|
||||
"""Test basic context formatting."""
|
||||
from src.models.hybrid_rag import HybridRAGResult
|
||||
|
||||
results = [
|
||||
HybridRAGResult(
|
||||
source_type="vector",
|
||||
title="Test Document",
|
||||
content="This is test content for formatting",
|
||||
page_id=1,
|
||||
rrf_score=0.5,
|
||||
final_rank=1,
|
||||
sources=["vector"]
|
||||
)
|
||||
]
|
||||
|
||||
context = hybrid_rag_service._format_context_for_llm(results)
|
||||
|
||||
assert "Test Document" in context
|
||||
assert "[VECTOR]" in context
|
||||
assert "test content" in context
|
||||
|
||||
def test_format_with_related_dossiers(self, hybrid_rag_service):
|
||||
"""Test context formatting with related dossiers."""
|
||||
from src.models.hybrid_rag import HybridRAGResult, RelatedDossier
|
||||
|
||||
results = [
|
||||
HybridRAGResult(
|
||||
source_type="vector+graph",
|
||||
title="Test Document",
|
||||
content="Content",
|
||||
page_id=1,
|
||||
rrf_score=0.5,
|
||||
final_rank=1,
|
||||
sources=["vector", "graph"],
|
||||
related_dossiers=[
|
||||
RelatedDossier(
|
||||
page_id=2,
|
||||
title="Related Doc",
|
||||
path="/test/related",
|
||||
tag="infrastructure",
|
||||
shared_entities=5
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
context = hybrid_rag_service._format_context_for_llm(results)
|
||||
|
||||
assert "Related research: infrastructure" in context
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests - Phase Testing
|
||||
# ============================================================================
|
||||
|
||||
class TestPhase0_QueryEnhancement:
|
||||
"""Test Phase 0: Query Enhancement (keyword/synonym extraction)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_keywords_basic(self, hybrid_rag_service):
|
||||
"""Test basic keyword extraction."""
|
||||
query = "Docker container orchestration with Kubernetes"
|
||||
|
||||
keywords_data = await hybrid_rag_service._extract_keywords_and_synonyms(query)
|
||||
|
||||
assert "core_keywords" in keywords_data
|
||||
assert "entities" in keywords_data
|
||||
assert "synonyms" in keywords_data
|
||||
assert "expansions" in keywords_data
|
||||
|
||||
# Should extract Docker and Kubernetes
|
||||
all_terms = (
|
||||
keywords_data["core_keywords"] +
|
||||
keywords_data["entities"]
|
||||
)
|
||||
assert any("docker" in term.lower() for term in all_terms)
|
||||
assert any("kubernetes" in term.lower() or "k8s" in term.lower() for term in all_terms)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_keywords_with_abbreviations(self, hybrid_rag_service):
|
||||
"""Test keyword extraction handles abbreviations."""
|
||||
query = "k8s cluster management"
|
||||
|
||||
keywords_data = await hybrid_rag_service._extract_keywords_and_synonyms(query)
|
||||
|
||||
# Should expand k8s to kubernetes
|
||||
all_data = json.dumps(keywords_data).lower()
|
||||
assert "k8s" in all_data or "kubernetes" in all_data
|
||||
|
||||
|
||||
class TestPhase1_ParallelRetrieval:
|
||||
"""Test Phase 1: Parallel Retrieval."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_retrieval_all_sources(
|
||||
self,
|
||||
hybrid_rag_service,
|
||||
test_wiki_page,
|
||||
test_graph_data,
|
||||
test_vector_data
|
||||
):
|
||||
"""Test parallel retrieval from all sources."""
|
||||
config = HybridRAGConfig(
|
||||
enable_vector=True,
|
||||
enable_graph=True,
|
||||
enable_web=True,
|
||||
vector_limit=5,
|
||||
graph_limit=5,
|
||||
web_limit=3
|
||||
)
|
||||
|
||||
keywords_data = {
|
||||
"core_keywords": ["docker", "kubernetes"],
|
||||
"entities": ["Docker", "Kubernetes"],
|
||||
"synonyms": {"docker": ["container"], "kubernetes": ["k8s"]},
|
||||
"expansions": {"k8s": ["kubernetes"]}
|
||||
}
|
||||
|
||||
results = await hybrid_rag_service._retrieve_parallel(
|
||||
query="docker kubernetes",
|
||||
user=TEST_USER,
|
||||
config=config,
|
||||
keywords_data=keywords_data
|
||||
)
|
||||
|
||||
assert "vector" in results
|
||||
assert "graph" in results
|
||||
assert "web" in results
|
||||
assert "timing" in results
|
||||
|
||||
# Should have timing for each source
|
||||
assert results["timing"]["vector_ms"] >= 0
|
||||
assert results["timing"]["graph_ms"] >= 0
|
||||
assert results["timing"]["web_ms"] >= 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_retrieval_graceful_degradation(self, hybrid_rag_service):
|
||||
"""Test graceful degradation when sources fail."""
|
||||
config = HybridRAGConfig(
|
||||
enable_vector=True,
|
||||
enable_graph=True,
|
||||
enable_web=True
|
||||
)
|
||||
|
||||
keywords_data = {"core_keywords": ["test"], "entities": [], "synonyms": {}, "expansions": {}}
|
||||
|
||||
# Even if some sources fail, should return results from working sources
|
||||
results = await hybrid_rag_service._retrieve_parallel(
|
||||
query="test query",
|
||||
user=TEST_USER,
|
||||
config=config,
|
||||
keywords_data=keywords_data
|
||||
)
|
||||
|
||||
# Should have all keys even if empty
|
||||
assert "vector" in results
|
||||
assert "graph" in results
|
||||
assert "web" in results
|
||||
|
||||
|
||||
class TestPhase3_Enrichment:
|
||||
"""Test Phase 3: Graph Enrichment."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_with_related_dossiers(
|
||||
self,
|
||||
hybrid_rag_service,
|
||||
graph_service,
|
||||
test_wiki_page,
|
||||
test_graph_data
|
||||
):
|
||||
"""Test enriching results with related dossiers."""
|
||||
# Create mock fused results
|
||||
fused_results = [
|
||||
{
|
||||
"result": {
|
||||
"page_id": test_wiki_page["id"],
|
||||
"title": test_wiki_page["title"],
|
||||
"content": "test"
|
||||
},
|
||||
"rrf_score": 0.5,
|
||||
"sources": ["vector"]
|
||||
}
|
||||
]
|
||||
|
||||
enriched = await hybrid_rag_service._enrich_with_related_dossiers(
|
||||
fused_results,
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
assert len(enriched) == 1
|
||||
assert "related_dossiers" in enriched[0]
|
||||
# May or may not have related docs depending on graph state
|
||||
assert isinstance(enriched[0]["related_dossiers"], list)
|
||||
|
||||
|
||||
class TestPhase6_Persistence:
|
||||
"""Test Phase 6: Search Persistence."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_search_creates_node(
|
||||
self,
|
||||
hybrid_rag_service,
|
||||
neo4j_client,
|
||||
test_wiki_page
|
||||
):
|
||||
"""Test that search persistence creates SearchQuery node."""
|
||||
keywords_data = {
|
||||
"core_keywords": ["docker", "kubernetes"],
|
||||
"entities": [],
|
||||
"synonyms": {},
|
||||
"expansions": {}
|
||||
}
|
||||
|
||||
raw_results = {
|
||||
"vector": [{"page_id": test_wiki_page["id"], "title": "Test", "content": "test"}],
|
||||
"graph": [],
|
||||
"web": []
|
||||
}
|
||||
|
||||
final_results = [
|
||||
{
|
||||
"result": {"page_id": test_wiki_page["id"], "title": "Test"},
|
||||
"rrf_score": 0.5,
|
||||
"final_rank": 1,
|
||||
"sources": ["vector"]
|
||||
}
|
||||
]
|
||||
|
||||
timing = {"total_ms": 1000}
|
||||
|
||||
search_id = await hybrid_rag_service._persist_search_for_librarian(
|
||||
query="test query",
|
||||
user=TEST_USER,
|
||||
keywords_data=keywords_data,
|
||||
raw_results=raw_results,
|
||||
final_results=final_results,
|
||||
timing=timing
|
||||
)
|
||||
|
||||
assert search_id is not None
|
||||
|
||||
# Verify SearchQuery node was created
|
||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||
user_label = get_neo4j_user_base_label(TEST_USER)
|
||||
|
||||
query = f"""
|
||||
MATCH (sq:{user_label}_SearchQuery:SearchQuery {{id: $search_id}})
|
||||
RETURN sq.query as query, sq.processed as processed
|
||||
"""
|
||||
|
||||
result = await neo4j_client.execute_query(query, {"search_id": search_id})
|
||||
assert len(result) == 1
|
||||
assert result[0]["query"] == "test query"
|
||||
assert result[0]["processed"] == False
|
||||
|
||||
# Cleanup
|
||||
cleanup_query = f"""
|
||||
MATCH (sq:{user_label}_SearchQuery:SearchQuery {{id: $search_id}})
|
||||
DETACH DELETE sq
|
||||
"""
|
||||
await neo4j_client.execute_query(cleanup_query, {"search_id": search_id})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# End-to-End Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestHybridRAG_EndToEnd:
|
||||
"""End-to-end tests for complete HybridRAG flow."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_search_pipeline(
|
||||
self,
|
||||
hybrid_rag_service,
|
||||
test_wiki_page,
|
||||
test_graph_data,
|
||||
test_vector_data
|
||||
):
|
||||
"""
|
||||
Test complete HybridRAG search pipeline with all 6 phases.
|
||||
|
||||
This is the main end-to-end test that validates:
|
||||
- Phase 0: Query enhancement
|
||||
- Phase 1: Parallel retrieval
|
||||
- Phase 2: RRF fusion
|
||||
- Phase 3: Enrichment
|
||||
- Phase 4: Re-ranking
|
||||
- Phase 5: Context formatting
|
||||
- Phase 6: Persistence
|
||||
"""
|
||||
query = "How does Docker work with Kubernetes?"
|
||||
config = HybridRAGConfig(
|
||||
vector_limit=5,
|
||||
graph_limit=5,
|
||||
web_limit=3,
|
||||
enable_reranking=True,
|
||||
enable_enrichment=True,
|
||||
final_result_count=10
|
||||
)
|
||||
|
||||
# Execute full search
|
||||
response = await hybrid_rag_service.search(
|
||||
query=query,
|
||||
user=TEST_USER,
|
||||
config=config
|
||||
)
|
||||
|
||||
# Validate response structure
|
||||
assert response.query == query
|
||||
assert response.keywords is not None
|
||||
assert response.results is not None
|
||||
assert response.context is not None
|
||||
assert response.source_counts is not None
|
||||
assert response.total_results >= 0
|
||||
assert response.timing is not None
|
||||
assert response.config_used == config
|
||||
assert response.search_id is not None
|
||||
|
||||
# Validate timing breakdown
|
||||
assert response.timing.query_enhancement_ms >= 0
|
||||
assert response.timing.vector_ms >= 0
|
||||
assert response.timing.graph_ms >= 0
|
||||
assert response.timing.web_ms >= 0
|
||||
assert response.timing.fusion_ms >= 0
|
||||
assert response.timing.enrichment_ms >= 0
|
||||
assert response.timing.reranking_ms >= 0
|
||||
assert response.timing.persistence_ms >= 0
|
||||
assert response.timing.total_ms >= 0
|
||||
|
||||
# Validate keywords extraction
|
||||
assert len(response.keywords.core_keywords) > 0
|
||||
|
||||
# Validate context is formatted
|
||||
assert len(response.context) > 0
|
||||
|
||||
# Log results for inspection
|
||||
print(f"\n=== HybridRAG E2E Test Results ===")
|
||||
print(f"Query: {response.query}")
|
||||
print(f"Total Results: {response.total_results}")
|
||||
print(f"Source Counts: {response.source_counts}")
|
||||
print(f"Keywords: {response.keywords.core_keywords}")
|
||||
print(f"Total Time: {response.timing.total_ms:.0f}ms")
|
||||
print(f"Search ID: {response.search_id}")
|
||||
|
||||
if response.results:
|
||||
print(f"\nTop Result:")
|
||||
top = response.results[0]
|
||||
print(f" Title: {top.title}")
|
||||
print(f" Source: {top.source_type}")
|
||||
print(f" RRF Score: {top.rrf_score:.4f}")
|
||||
print(f" Rank: {top.final_rank}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_disabled_sources(
|
||||
self,
|
||||
hybrid_rag_service,
|
||||
test_wiki_page,
|
||||
test_vector_data
|
||||
):
|
||||
"""Test HybridRAG with some sources disabled."""
|
||||
config = HybridRAGConfig(
|
||||
enable_vector=True,
|
||||
enable_graph=False, # Disabled
|
||||
enable_web=False, # Disabled
|
||||
enable_reranking=False,
|
||||
final_result_count=5
|
||||
)
|
||||
|
||||
response = await hybrid_rag_service.search(
|
||||
query="docker containers",
|
||||
user=TEST_USER,
|
||||
config=config
|
||||
)
|
||||
|
||||
# Should only have vector results
|
||||
assert response.total_results >= 0
|
||||
if response.total_results > 0:
|
||||
assert all(
|
||||
"vector" in result.sources
|
||||
for result in response.results
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_performance_target(
|
||||
self,
|
||||
hybrid_rag_service,
|
||||
test_wiki_page,
|
||||
test_graph_data,
|
||||
test_vector_data
|
||||
):
|
||||
"""Test that search completes within performance target (<3.5s)."""
|
||||
import time
|
||||
|
||||
config = HybridRAGConfig()
|
||||
|
||||
start = time.time()
|
||||
response = await hybrid_rag_service.search(
|
||||
query="kubernetes orchestration",
|
||||
user=TEST_USER,
|
||||
config=config
|
||||
)
|
||||
duration_ms = (time.time() - start) * 1000
|
||||
|
||||
print(f"\nPerformance: {duration_ms:.0f}ms (target: <3500ms)")
|
||||
|
||||
# Soft assertion - warn if exceeds target
|
||||
if duration_ms > 3500:
|
||||
print(f"WARNING: Search exceeded 3.5s target ({duration_ms:.0f}ms)")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Cleanup Tests
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_test_data(neo4j_client, qdrant_client):
|
||||
"""
|
||||
Cleanup test data for llm-tester user.
|
||||
|
||||
Run this to clean up test data:
|
||||
pytest tests/test_hybrid_rag.py::test_cleanup_test_data -v -s
|
||||
"""
|
||||
from src.core.multi_tenancy import (
|
||||
get_neo4j_user_base_label,
|
||||
get_neo4j_user_label,
|
||||
get_qdrant_collection_name
|
||||
)
|
||||
|
||||
# Clean Neo4j
|
||||
user_base_label = get_neo4j_user_base_label(TEST_USER)
|
||||
user_doc_label = get_neo4j_user_label(TEST_USER)
|
||||
|
||||
# Delete all test user nodes
|
||||
delete_query = f"""
|
||||
MATCH (n)
|
||||
WHERE n:{user_base_label} OR n:{user_doc_label}
|
||||
DETACH DELETE n
|
||||
"""
|
||||
await neo4j_client.execute_query(delete_query, {})
|
||||
|
||||
# Clean Qdrant
|
||||
collection_name = get_qdrant_collection_name(TEST_USER)
|
||||
try:
|
||||
await qdrant_client.delete_collection(collection_name)
|
||||
except:
|
||||
pass
|
||||
|
||||
print(f"\n✓ Cleaned up test data for user: {TEST_USER}")
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Tests for IngestionService - document ingestion operations.
|
||||
|
||||
Tests cover:
|
||||
- Single page ingestion
|
||||
- Batch ingestion
|
||||
- Full re-index (ingest_all_pages)
|
||||
- list_all_pages usage
|
||||
|
||||
Run with: pytest tests/test_ingestion.py -v -s
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from src.services.ingestion_service import IngestionService
|
||||
from src.models.ingestion import (
|
||||
IngestionRequest,
|
||||
IngestionResult,
|
||||
BatchIngestionRequest,
|
||||
BatchIngestionResult
|
||||
)
|
||||
|
||||
|
||||
# Test constants
|
||||
TEST_USER = "ingestion-tester"
|
||||
TEST_PAGE_ID = 456
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_vector_service():
|
||||
"""Mock Vector service."""
|
||||
mock = AsyncMock()
|
||||
mock.update_from_page = AsyncMock(return_value=MagicMock(
|
||||
chunks_created=2,
|
||||
chunks_deleted=0,
|
||||
success=True
|
||||
))
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_graph_service():
|
||||
"""Mock Graph service."""
|
||||
mock = AsyncMock()
|
||||
mock.update_from_page = AsyncMock(return_value=MagicMock(
|
||||
entities_extracted=5,
|
||||
relationships_created=5,
|
||||
success=True
|
||||
))
|
||||
mock.create_entity_mention_links = AsyncMock(return_value=3)
|
||||
mock.get_all_entities = AsyncMock(return_value=[])
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_wiki_client():
|
||||
"""Mock Wiki.js client."""
|
||||
mock = AsyncMock()
|
||||
mock.get_page = AsyncMock(return_value={
|
||||
"id": TEST_PAGE_ID,
|
||||
"title": "Test Page",
|
||||
"path": f"users/{TEST_USER}/test",
|
||||
"content": "Test content here.",
|
||||
"tags": ["test"]
|
||||
})
|
||||
mock.list_all_pages = AsyncMock(return_value=[
|
||||
{"id": 1, "path": f"users/{TEST_USER}/page1", "title": "Page 1"},
|
||||
{"id": 2, "path": f"users/{TEST_USER}/page2", "title": "Page 2"},
|
||||
{"id": 3, "path": f"users/{TEST_USER}/page3", "title": "Page 3"},
|
||||
])
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ingestion_service(mock_vector_service, mock_graph_service, mock_wiki_client):
|
||||
"""Get IngestionService with mocked dependencies."""
|
||||
return IngestionService(
|
||||
vector_service=mock_vector_service,
|
||||
graph_service=mock_graph_service,
|
||||
wiki_client=mock_wiki_client
|
||||
)
|
||||
|
||||
|
||||
class TestSinglePageIngestion:
|
||||
"""Test single page ingestion."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_page_success(
|
||||
self,
|
||||
ingestion_service,
|
||||
mock_wiki_client
|
||||
):
|
||||
"""Test successful page ingestion."""
|
||||
result = await ingestion_service.ingest_page(
|
||||
page_id=TEST_PAGE_ID,
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.page_id == TEST_PAGE_ID
|
||||
mock_wiki_client.get_page.assert_called_once_with(TEST_PAGE_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_page_not_found(
|
||||
self,
|
||||
ingestion_service,
|
||||
mock_wiki_client
|
||||
):
|
||||
"""Test ingestion when page not found."""
|
||||
mock_wiki_client.get_page.return_value = None
|
||||
|
||||
result = await ingestion_service.ingest_page(
|
||||
page_id=999,
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "not found" in result.error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_page_skip_vectors(
|
||||
self,
|
||||
ingestion_service,
|
||||
mock_vector_service,
|
||||
mock_graph_service
|
||||
):
|
||||
"""Test ingestion with vectors skipped."""
|
||||
result = await ingestion_service.ingest_page(
|
||||
page_id=TEST_PAGE_ID,
|
||||
user=TEST_USER,
|
||||
skip_vectors=True
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
# Vector service should not be called
|
||||
mock_vector_service.update_from_page.assert_not_called()
|
||||
# Graph service should still be called
|
||||
mock_graph_service.update_from_page.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_page_skip_graph(
|
||||
self,
|
||||
ingestion_service,
|
||||
mock_vector_service,
|
||||
mock_graph_service
|
||||
):
|
||||
"""Test ingestion with graph skipped."""
|
||||
result = await ingestion_service.ingest_page(
|
||||
page_id=TEST_PAGE_ID,
|
||||
user=TEST_USER,
|
||||
skip_graph=True
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
# Vector service should be called
|
||||
mock_vector_service.update_from_page.assert_called_once()
|
||||
# Graph service should not be called
|
||||
mock_graph_service.update_from_page.assert_not_called()
|
||||
|
||||
|
||||
class TestBatchIngestion:
|
||||
"""Test batch page ingestion."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_batch_success(
|
||||
self,
|
||||
ingestion_service,
|
||||
mock_wiki_client
|
||||
):
|
||||
"""Test successful batch ingestion."""
|
||||
result = await ingestion_service.ingest_batch(
|
||||
page_ids=[1, 2, 3],
|
||||
user=TEST_USER,
|
||||
max_concurrent=2
|
||||
)
|
||||
|
||||
assert result.total_pages == 3
|
||||
assert result.successful == 3
|
||||
assert result.failed == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_batch_with_failures(
|
||||
self,
|
||||
ingestion_service,
|
||||
mock_wiki_client
|
||||
):
|
||||
"""Test batch ingestion with some failures."""
|
||||
# Make page 2 not found
|
||||
def get_page_side_effect(page_id):
|
||||
if page_id == 2:
|
||||
return None
|
||||
return {
|
||||
"id": page_id,
|
||||
"title": f"Page {page_id}",
|
||||
"path": f"users/{TEST_USER}/page{page_id}",
|
||||
"content": "Content",
|
||||
"tags": []
|
||||
}
|
||||
|
||||
mock_wiki_client.get_page.side_effect = get_page_side_effect
|
||||
|
||||
result = await ingestion_service.ingest_batch(
|
||||
page_ids=[1, 2, 3],
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
assert result.total_pages == 3
|
||||
assert result.successful == 2
|
||||
assert result.failed == 1
|
||||
|
||||
|
||||
class TestIngestAllPages:
|
||||
"""Test full re-index (ingest_all_pages)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_all_uses_list_all_pages(
|
||||
self,
|
||||
ingestion_service,
|
||||
mock_wiki_client
|
||||
):
|
||||
"""Test that ingest_all_pages uses list_all_pages (not search)."""
|
||||
result = await ingestion_service.ingest_all_pages(
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
# Should use list_all_pages, not search_pages
|
||||
mock_wiki_client.list_all_pages.assert_called_once()
|
||||
# Should have processed 3 pages from the mock
|
||||
assert result.total_pages == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_all_with_path_prefix(
|
||||
self,
|
||||
ingestion_service,
|
||||
mock_wiki_client
|
||||
):
|
||||
"""Test ingest_all_pages with path prefix filter."""
|
||||
await ingestion_service.ingest_all_pages(
|
||||
user=TEST_USER,
|
||||
path_prefix=f"users/{TEST_USER}/technology"
|
||||
)
|
||||
|
||||
mock_wiki_client.list_all_pages.assert_called_once_with(
|
||||
path_prefix=f"users/{TEST_USER}/technology"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_all_empty_wiki(
|
||||
self,
|
||||
ingestion_service,
|
||||
mock_wiki_client
|
||||
):
|
||||
"""Test ingest_all_pages when no pages found."""
|
||||
mock_wiki_client.list_all_pages.return_value = []
|
||||
|
||||
result = await ingestion_service.ingest_all_pages(
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
assert result.total_pages == 0
|
||||
assert result.successful == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_all_respects_max_concurrent(
|
||||
self,
|
||||
ingestion_service,
|
||||
mock_wiki_client
|
||||
):
|
||||
"""Test that max_concurrent parameter is passed through."""
|
||||
# Create many pages
|
||||
mock_wiki_client.list_all_pages.return_value = [
|
||||
{"id": i, "path": f"users/{TEST_USER}/page{i}", "title": f"Page {i}"}
|
||||
for i in range(20)
|
||||
]
|
||||
|
||||
result = await ingestion_service.ingest_all_pages(
|
||||
user=TEST_USER,
|
||||
max_concurrent=5
|
||||
)
|
||||
|
||||
assert result.total_pages == 20
|
||||
|
||||
|
||||
class TestIngestionModels:
|
||||
"""Test ingestion request/response models."""
|
||||
|
||||
def test_ingestion_request_defaults(self):
|
||||
"""Test IngestionRequest default values."""
|
||||
request = IngestionRequest(
|
||||
page_id=123,
|
||||
user="testuser"
|
||||
)
|
||||
assert request.page_id == 123
|
||||
assert request.user == "testuser"
|
||||
assert request.force_refresh is False
|
||||
assert request.skip_vectors is False
|
||||
assert request.skip_graph is False
|
||||
|
||||
def test_batch_ingestion_request(self):
|
||||
"""Test BatchIngestionRequest."""
|
||||
request = BatchIngestionRequest(
|
||||
page_ids=[1, 2, 3],
|
||||
user="testuser",
|
||||
max_concurrent=5
|
||||
)
|
||||
assert len(request.page_ids) == 3
|
||||
assert request.max_concurrent == 5
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Integration tests for Library Desk service clients.
|
||||
|
||||
These tests require actual service connectivity:
|
||||
- Neo4j running at bolt://neo4j:7687
|
||||
- Qdrant running at http://qdrant:6333
|
||||
- Wiki.js running at http://wiki:3000
|
||||
- SearXNG running at http://searxng:8080
|
||||
- Ollama running at http://ollama:11434
|
||||
|
||||
Run with: pytest tests/test_integration.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
from src.clients.qdrant_client import QdrantClientWrapper
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.clients.searxng_client import SearXNGClient
|
||||
from src.clients.ollama_client import OllamaClient
|
||||
from src.jobs.job_manager import JobManager, JobType, JobStatus
|
||||
from src.config import get_settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings():
|
||||
"""Get application settings."""
|
||||
return get_settings()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
|
||||
"""Get connected Neo4j client."""
|
||||
client = Neo4jClient(
|
||||
uri=settings.neo4j_uri,
|
||||
user=settings.neo4j_user,
|
||||
password=settings.neo4j_password
|
||||
)
|
||||
await client.connect()
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qdrant_client(settings) -> QdrantClientWrapper:
|
||||
"""Get Qdrant client."""
|
||||
return QdrantClientWrapper(url=settings.qdrant_url)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def wikijs_client(settings) -> AsyncGenerator[WikiJSClient, None]:
|
||||
"""Get Wiki.js client."""
|
||||
client = WikiJSClient(
|
||||
base_url=settings.wikijs_url,
|
||||
api_key=settings.wikijs_api_key
|
||||
)
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def searxng_client(settings) -> AsyncGenerator[SearXNGClient, None]:
|
||||
"""Get SearXNG client."""
|
||||
client = SearXNGClient(base_url=settings.searxng_url)
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ollama_client(settings) -> AsyncGenerator[OllamaClient, None]:
|
||||
"""Get Ollama client."""
|
||||
client = OllamaClient(
|
||||
base_url=settings.ollama_url,
|
||||
model=settings.ollama_model
|
||||
)
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def job_manager(settings) -> AsyncGenerator[JobManager, None]:
|
||||
"""Get job manager."""
|
||||
manager = JobManager(redis_url=settings.redis_url)
|
||||
await manager.connect()
|
||||
yield manager
|
||||
await manager.close()
|
||||
|
||||
|
||||
class TestNeo4jIntegration:
|
||||
"""Test Neo4j connectivity and basic operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection(self, neo4j_client):
|
||||
"""Test Neo4j connection."""
|
||||
result = await neo4j_client.execute_query("RETURN 1 as test", {})
|
||||
assert result
|
||||
assert result[0]["test"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_get_document(self, neo4j_client, test_user):
|
||||
"""Test creating and retrieving a document node."""
|
||||
doc_id = "test_doc_integration"
|
||||
|
||||
# Create document
|
||||
doc = await neo4j_client.create_document_node(
|
||||
user=test_user,
|
||||
doc_id=doc_id,
|
||||
properties={
|
||||
"title": "Integration Test Document",
|
||||
"source": "test"
|
||||
}
|
||||
)
|
||||
assert doc is not None
|
||||
assert doc["id"] == doc_id
|
||||
|
||||
# Retrieve document
|
||||
retrieved = await neo4j_client.get_document_node(test_user, doc_id)
|
||||
assert retrieved is not None
|
||||
assert retrieved["id"] == doc_id
|
||||
assert retrieved["title"] == "Integration Test Document"
|
||||
|
||||
# Cleanup
|
||||
deleted = await neo4j_client.delete_document_node(test_user, doc_id)
|
||||
assert deleted is True
|
||||
|
||||
|
||||
class TestQdrantIntegration:
|
||||
"""Test Qdrant connectivity and basic operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_creation(self, qdrant_client, test_user):
|
||||
"""Test creating a collection."""
|
||||
await qdrant_client.ensure_collection(test_user)
|
||||
|
||||
collection_name = qdrant_client.get_collection_name(test_user)
|
||||
collections = qdrant_client.client.get_collections()
|
||||
collection_names = [c.name for c in collections.collections]
|
||||
|
||||
assert collection_name in collection_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_and_search(self, qdrant_client, test_user):
|
||||
"""Test upserting and searching chunks."""
|
||||
await qdrant_client.ensure_collection(test_user)
|
||||
|
||||
# Create test chunks with 768-dimensional embeddings
|
||||
chunks = [
|
||||
{"content": "Test chunk 1", "metadata": {}},
|
||||
{"content": "Test chunk 2", "metadata": {}}
|
||||
]
|
||||
embeddings = [
|
||||
[0.1] * 768,
|
||||
[0.2] * 768
|
||||
]
|
||||
|
||||
# Upsert
|
||||
count = await qdrant_client.upsert_document_chunks(
|
||||
user=test_user,
|
||||
doc_id="test_doc_qdrant",
|
||||
chunks=chunks,
|
||||
embeddings=embeddings
|
||||
)
|
||||
assert count == 2
|
||||
|
||||
# Search
|
||||
results = await qdrant_client.search(
|
||||
user=test_user,
|
||||
query_vector=[0.1] * 768,
|
||||
limit=5,
|
||||
score_threshold=0.0
|
||||
)
|
||||
assert len(results) > 0
|
||||
|
||||
# Cleanup
|
||||
deleted = await qdrant_client.delete_document(test_user, "test_doc_qdrant")
|
||||
assert deleted is True
|
||||
|
||||
|
||||
class TestWikiJSIntegration:
|
||||
"""Test Wiki.js connectivity and basic operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pages(self, wikijs_client):
|
||||
"""Test listing pages."""
|
||||
pages = await wikijs_client.list_pages(limit=10)
|
||||
assert isinstance(pages, list)
|
||||
# May be empty if wiki is new
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_pages(self, wikijs_client):
|
||||
"""Test listing all pages with pagination support."""
|
||||
pages = await wikijs_client.list_all_pages(path_prefix="users/")
|
||||
assert isinstance(pages, list)
|
||||
# Verify each page has expected fields
|
||||
for page in pages[:5]: # Check first 5
|
||||
assert "id" in page
|
||||
assert "path" in page
|
||||
assert "title" in page
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_taxonomy_structure(self, wikijs_client):
|
||||
"""Test getting taxonomy structure for a user."""
|
||||
taxonomy = await wikijs_client.get_taxonomy_structure("users/jpmschweitzer")
|
||||
assert isinstance(taxonomy, dict)
|
||||
# Each key should be a category, value should be list of subcategories
|
||||
for category, subcategories in taxonomy.items():
|
||||
assert isinstance(category, str)
|
||||
assert isinstance(subcategories, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_pages(self, wikijs_client):
|
||||
"""Test searching pages."""
|
||||
results = await wikijs_client.search_pages("test")
|
||||
assert isinstance(results, list)
|
||||
|
||||
|
||||
class TestSearXNGIntegration:
|
||||
"""Test SearXNG connectivity and search."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_general_search(self, searxng_client):
|
||||
"""Test general web search."""
|
||||
results = await searxng_client.search_general("python programming", limit=5)
|
||||
assert isinstance(results, list)
|
||||
if results:
|
||||
assert "title" in results[0]
|
||||
assert "url" in results[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_suggestions(self, searxng_client):
|
||||
"""Test getting search suggestions."""
|
||||
suggestions = await searxng_client.get_suggestions("pytho")
|
||||
assert isinstance(suggestions, list)
|
||||
|
||||
|
||||
class TestOllamaIntegration:
|
||||
"""Test Ollama connectivity and embeddings."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check(self, ollama_client):
|
||||
"""Test Ollama health check."""
|
||||
is_healthy = await ollama_client.health_check()
|
||||
# May be False if model not pulled
|
||||
assert isinstance(is_healthy, bool)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_models(self, ollama_client):
|
||||
"""Test listing available models."""
|
||||
models = await ollama_client.list_models()
|
||||
assert isinstance(models, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(
|
||||
True, # Skip by default as embeddings can be slow
|
||||
reason="Embedding generation is slow - enable manually if needed"
|
||||
)
|
||||
async def test_generate_embedding(self, ollama_client):
|
||||
"""Test generating a single embedding."""
|
||||
embedding = await ollama_client.embed("test text")
|
||||
if embedding:
|
||||
assert isinstance(embedding, list)
|
||||
assert len(embedding) > 0
|
||||
|
||||
|
||||
class TestJobManagerIntegration:
|
||||
"""Test Job Manager with Redis."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_get_job(self, job_manager, test_user):
|
||||
"""Test creating and retrieving a job."""
|
||||
# Create job
|
||||
job_id = await job_manager.create_job(
|
||||
job_type=JobType.DOCUMENT_INGESTION,
|
||||
user=test_user,
|
||||
parameters={"doc_url": "https://example.com/doc.pdf"}
|
||||
)
|
||||
assert job_id
|
||||
|
||||
# Get job
|
||||
job = await job_manager.get_job(job_id)
|
||||
assert job is not None
|
||||
assert job["job_id"] == job_id
|
||||
assert job["user"] == test_user
|
||||
assert job["status"] == JobStatus.QUEUED.value
|
||||
|
||||
# Update job
|
||||
await job_manager.update_job_status(
|
||||
job_id,
|
||||
JobStatus.PROCESSING,
|
||||
progress=50
|
||||
)
|
||||
|
||||
updated_job = await job_manager.get_job(job_id)
|
||||
assert updated_job["status"] == JobStatus.PROCESSING.value
|
||||
assert updated_job["progress"] == 50
|
||||
|
||||
# Cleanup
|
||||
deleted = await job_manager.delete_job(job_id, test_user)
|
||||
assert deleted is True
|
||||
|
||||
|
||||
class TestDependencyInjection:
|
||||
"""Test dependency injection and lifecycle management."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_clients(self):
|
||||
"""Test client startup."""
|
||||
from src.core.dependencies import startup_clients
|
||||
|
||||
# Should not raise exceptions
|
||||
await startup_clients()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_health(self):
|
||||
"""Test health check for all services."""
|
||||
from src.core.dependencies import check_service_health
|
||||
|
||||
health = await check_service_health()
|
||||
|
||||
assert isinstance(health, dict)
|
||||
assert "neo4j" in health
|
||||
assert "qdrant" in health
|
||||
assert "wikijs" in health
|
||||
assert "searxng" in health
|
||||
assert "ollama" in health
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_clients(self):
|
||||
"""Test client shutdown."""
|
||||
from src.core.dependencies import shutdown_clients
|
||||
|
||||
# Should not raise exceptions
|
||||
await shutdown_clients()
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for multi-tenancy helpers."""
|
||||
|
||||
import pytest
|
||||
from src.core.multi_tenancy import (
|
||||
sanitize_user_id,
|
||||
get_qdrant_collection_name,
|
||||
get_wikijs_namespace,
|
||||
get_neo4j_user_label,
|
||||
validate_user_id,
|
||||
is_path_in_user_namespace,
|
||||
DEFAULT_USER
|
||||
)
|
||||
|
||||
|
||||
class TestSanitizeUserId:
|
||||
"""Test user ID sanitization."""
|
||||
|
||||
def test_lowercase_conversion(self):
|
||||
assert sanitize_user_id("JohnDoe") == "johndoe"
|
||||
|
||||
def test_email_conversion(self):
|
||||
assert sanitize_user_id("john@example.com") == "john_at_example_com"
|
||||
|
||||
def test_dot_conversion(self):
|
||||
assert sanitize_user_id("john.doe") == "john_doe"
|
||||
|
||||
def test_space_conversion(self):
|
||||
assert sanitize_user_id("John Doe") == "john_doe"
|
||||
|
||||
def test_special_chars_removal(self):
|
||||
assert sanitize_user_id("john-doe!") == "john_doe"
|
||||
|
||||
def test_consecutive_underscores(self):
|
||||
assert sanitize_user_id("john__doe") == "john_doe"
|
||||
|
||||
def test_leading_trailing_underscores(self):
|
||||
assert sanitize_user_id("_john_") == "john"
|
||||
|
||||
|
||||
class TestQdrantCollectionName:
|
||||
"""Test Qdrant collection name generation."""
|
||||
|
||||
def test_simple_user(self):
|
||||
assert get_qdrant_collection_name("jpmschweitzer") == "library_desk_jpmschweitzer"
|
||||
|
||||
def test_email_user(self):
|
||||
assert get_qdrant_collection_name("john@example.com") == "library_desk_john_at_example_com"
|
||||
|
||||
def test_default_user(self):
|
||||
assert get_qdrant_collection_name(DEFAULT_USER) == f"library_desk_{DEFAULT_USER}"
|
||||
|
||||
|
||||
class TestWikijsNamespace:
|
||||
"""Test Wiki.js namespace generation."""
|
||||
|
||||
def test_simple_user(self):
|
||||
assert get_wikijs_namespace("jpmschweitzer") == "/users/jpmschweitzer"
|
||||
|
||||
def test_email_user(self):
|
||||
assert get_wikijs_namespace("john@example.com") == "/users/john_at_example_com"
|
||||
|
||||
def test_starts_with_slash(self):
|
||||
namespace = get_wikijs_namespace("testuser")
|
||||
assert namespace.startswith("/")
|
||||
|
||||
|
||||
class TestNeo4jUserLabel:
|
||||
"""Test Neo4j user label generation."""
|
||||
|
||||
def test_simple_user(self):
|
||||
assert get_neo4j_user_label("jpmschweitzer") == "User_Jpmschweitzer_Document"
|
||||
|
||||
def test_email_user(self):
|
||||
result = get_neo4j_user_label("john@example.com")
|
||||
# Should be title case
|
||||
assert result == "User_John_At_Example_Com_Document"
|
||||
|
||||
def test_title_case(self):
|
||||
result = get_neo4j_user_label("john_doe")
|
||||
assert result == "User_John_Doe_Document"
|
||||
|
||||
|
||||
class TestValidateUserId:
|
||||
"""Test user ID validation."""
|
||||
|
||||
def test_valid_simple(self):
|
||||
assert validate_user_id("jpmschweitzer") is True
|
||||
|
||||
def test_valid_email(self):
|
||||
assert validate_user_id("john@example.com") is True
|
||||
|
||||
def test_empty_invalid(self):
|
||||
assert validate_user_id("") is False
|
||||
|
||||
def test_too_long_invalid(self):
|
||||
assert validate_user_id("a" * 101) is False
|
||||
|
||||
def test_no_alphanumeric_invalid(self):
|
||||
assert validate_user_id("___") is False
|
||||
|
||||
|
||||
class TestPathInNamespace:
|
||||
"""Test path namespace checking."""
|
||||
|
||||
def test_path_in_namespace(self):
|
||||
assert is_path_in_user_namespace("/users/jpmschweitzer/projects", "jpmschweitzer") is True
|
||||
|
||||
def test_path_not_in_namespace(self):
|
||||
assert is_path_in_user_namespace("/users/other/projects", "jpmschweitzer") is False
|
||||
|
||||
def test_public_path_not_in_namespace(self):
|
||||
assert is_path_in_user_namespace("/public/docs", "jpmschweitzer") is False
|
||||
|
||||
def test_root_path(self):
|
||||
assert is_path_in_user_namespace("/users/test", "test") is True
|
||||
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
Tests for Wiki.js Change Listener
|
||||
|
||||
Tests the PostgreSQL NOTIFY/LISTEN change detection system including:
|
||||
- Database connection and listener startup
|
||||
- Notification handling (INSERT/UPDATE/DELETE)
|
||||
- Loop prevention (automated user filtering)
|
||||
- Debouncing (duplicate notification filtering)
|
||||
- Page processing
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from src.services.wiki_change_listener import WikiChangeListener
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings():
|
||||
"""Mock settings for testing."""
|
||||
settings = MagicMock()
|
||||
settings.wikijs_db_host = "postgres-shared"
|
||||
settings.wikijs_db_port = 5432
|
||||
settings.wikijs_db_user = "library_desk_listener"
|
||||
settings.wikijs_db_password = "test_password"
|
||||
settings.wikijs_db_name = "library"
|
||||
settings.wikijs_username = "librarian@schweitz.net"
|
||||
settings.wikijs_change_listener_debounce_seconds = 5
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def listener(mock_settings):
|
||||
"""Create a WikiChangeListener instance with mocked settings."""
|
||||
with patch('src.services.wiki_change_listener.get_settings', return_value=mock_settings):
|
||||
return WikiChangeListener()
|
||||
|
||||
|
||||
class TestWikiChangeListener:
|
||||
"""Test suite for WikiChangeListener."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_listener_initialization(self, listener, mock_settings):
|
||||
"""Test that listener initializes with correct settings."""
|
||||
assert listener.settings == mock_settings
|
||||
assert listener.connection is None
|
||||
assert listener.running is False
|
||||
assert listener._debounce_seconds == 5
|
||||
assert len(listener._recent_notifications) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_automated_user_filtering(self, listener):
|
||||
"""Test that automated users are correctly identified."""
|
||||
# Automated users should be filtered
|
||||
assert listener._is_automated_user("librarian@schweitz.net") is True
|
||||
assert listener._is_automated_user("library-desk@system") is True
|
||||
assert listener._is_automated_user("automation@system") is True
|
||||
assert listener._is_automated_user("bot@system") is True
|
||||
|
||||
# Case insensitive
|
||||
assert listener._is_automated_user("LIBRARIAN@SCHWEITZ.NET") is True
|
||||
|
||||
# Regular users should not be filtered
|
||||
assert listener._is_automated_user("user@example.com") is False
|
||||
assert listener._is_automated_user("john@example.com") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debouncing_prevents_duplicates(self, listener):
|
||||
"""Test that debouncing prevents duplicate processing."""
|
||||
page_id = 123
|
||||
|
||||
# First notification - should not be filtered
|
||||
assert listener._is_recently_processed(page_id) is False
|
||||
|
||||
# Mark as processed
|
||||
listener._mark_as_processed(page_id)
|
||||
|
||||
# Immediate second notification - should be filtered (within debounce window)
|
||||
assert listener._is_recently_processed(page_id) is True
|
||||
|
||||
# Different page - should not be filtered
|
||||
assert listener._is_recently_processed(456) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debouncing_expires_after_window(self, listener):
|
||||
"""Test that debouncing expires after the configured time window."""
|
||||
page_id = 123
|
||||
|
||||
# Mark as processed with old timestamp (outside debounce window)
|
||||
listener._recent_notifications[page_id] = datetime.now() - timedelta(seconds=10)
|
||||
|
||||
# Should not be filtered anymore (10 seconds > 5 second debounce)
|
||||
assert listener._is_recently_processed(page_id) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_as_processed_cleanup(self, listener):
|
||||
"""Test that old entries are cleaned up to prevent memory growth."""
|
||||
# Add 101 entries to trigger cleanup (threshold is 100)
|
||||
for i in range(101):
|
||||
listener._mark_as_processed(i)
|
||||
|
||||
# Should only keep last 100 entries
|
||||
assert len(listener._recent_notifications) == 100
|
||||
|
||||
# Oldest entry (0) should be removed
|
||||
assert 0 not in listener._recent_notifications
|
||||
|
||||
# Newest entries should be kept
|
||||
assert 100 in listener._recent_notifications
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_payload_parsing(self, listener):
|
||||
"""Test that notification payloads are correctly parsed."""
|
||||
mock_connection = AsyncMock()
|
||||
|
||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
||||
# Test UPDATE notification
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'UPDATE:123:user@example.com'
|
||||
)
|
||||
|
||||
mock_process.assert_called_once()
|
||||
call_args = mock_process.call_args[1]
|
||||
assert call_args['page_id'] == 123
|
||||
assert call_args['event'] == 'page.update'
|
||||
assert call_args['user'] == 'user'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_operations_mapping(self, listener):
|
||||
"""Test that database operations map to correct webhook events."""
|
||||
mock_connection = AsyncMock()
|
||||
|
||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
||||
# INSERT -> page.create (use page 100)
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'INSERT:100:user@example.com'
|
||||
)
|
||||
mock_process.assert_called_once()
|
||||
assert mock_process.call_args[1]['event'] == 'page.create'
|
||||
|
||||
mock_process.reset_mock()
|
||||
|
||||
# UPDATE -> page.update (use different page 200 to avoid debouncing)
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'UPDATE:200:user@example.com'
|
||||
)
|
||||
mock_process.assert_called_once()
|
||||
assert mock_process.call_args[1]['event'] == 'page.update'
|
||||
|
||||
mock_process.reset_mock()
|
||||
|
||||
# DELETE -> page.delete (use different page 300 to avoid debouncing)
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'DELETE:300:user@example.com'
|
||||
)
|
||||
mock_process.assert_called_once()
|
||||
assert mock_process.call_args[1]['event'] == 'page.delete'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_automated_user_notification_filtered(self, listener):
|
||||
"""Test that notifications from automated users are filtered out."""
|
||||
mock_connection = AsyncMock()
|
||||
|
||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
||||
# Notification from automated user should be skipped
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'UPDATE:123:librarian@schweitz.net'
|
||||
)
|
||||
|
||||
# Process should NOT be called
|
||||
mock_process.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_notification_filtered(self, listener):
|
||||
"""Test that duplicate notifications within debounce window are filtered."""
|
||||
mock_connection = AsyncMock()
|
||||
page_id = 123
|
||||
|
||||
# Mark page as recently processed
|
||||
listener._mark_as_processed(page_id)
|
||||
|
||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
||||
# Duplicate notification should be skipped
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
f'UPDATE:{page_id}:user@example.com'
|
||||
)
|
||||
|
||||
# Process should NOT be called
|
||||
mock_process.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_notification_payload_handled(self, listener):
|
||||
"""Test that invalid notification payloads are handled gracefully."""
|
||||
mock_connection = AsyncMock()
|
||||
|
||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
||||
# Invalid payload (too few parts)
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'INVALID:123' # Missing user email
|
||||
)
|
||||
|
||||
# Should not crash and should not process
|
||||
mock_process.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_to_username_extraction(self, listener):
|
||||
"""Test that user email is correctly extracted to username."""
|
||||
mock_connection = AsyncMock()
|
||||
|
||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'UPDATE:123:john.doe@example.com'
|
||||
)
|
||||
|
||||
# Should extract 'john.doe' from email
|
||||
call_args = mock_process.call_args[1]
|
||||
assert call_args['user'] == 'john.doe'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_without_at_sign_fallback(self, listener):
|
||||
"""Test fallback when email doesn't contain @ sign."""
|
||||
mock_connection = AsyncMock()
|
||||
|
||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'UPDATE:123:invaliduser'
|
||||
)
|
||||
|
||||
# Should use default user
|
||||
call_args = mock_process.call_args[1]
|
||||
assert call_args['user'] == 'jpmschweitzer'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_page_delete_calls_cleanup(self, listener):
|
||||
"""Test that DELETE events call cleanup_deleted_page."""
|
||||
with patch('src.routers.webhooks.cleanup_deleted_page', new_callable=AsyncMock) as mock_cleanup, \
|
||||
patch('src.services.wiki_change_listener.get_ingestion_service') as mock_service:
|
||||
|
||||
await listener._process_page_change(
|
||||
page_id=123,
|
||||
event='page.delete',
|
||||
user='testuser'
|
||||
)
|
||||
|
||||
mock_cleanup.assert_called_once()
|
||||
call_args = mock_cleanup.call_args[1]
|
||||
assert call_args['page_id'] == 123
|
||||
assert call_args['user'] == 'testuser'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_page_create_calls_process_wiki_page_change(self, listener):
|
||||
"""Test that CREATE events call process_wiki_page_change."""
|
||||
mock_page = MagicMock()
|
||||
mock_page.title = "Test Page"
|
||||
|
||||
with patch('src.routers.webhooks.process_wiki_page_change', new_callable=AsyncMock) as mock_process, \
|
||||
patch('src.core.dependencies.get_wiki_service') as mock_wiki_service_factory, \
|
||||
patch('src.services.wiki_change_listener.get_ingestion_service'):
|
||||
|
||||
mock_wiki_service = AsyncMock()
|
||||
mock_wiki_service.get_page.return_value = mock_page
|
||||
mock_wiki_service_factory.return_value = mock_wiki_service
|
||||
|
||||
await listener._process_page_change(
|
||||
page_id=123,
|
||||
event='page.create',
|
||||
user='testuser'
|
||||
)
|
||||
|
||||
mock_process.assert_called_once()
|
||||
call_args = mock_process.call_args[1]
|
||||
assert call_args['page_id'] == 123
|
||||
assert call_args['page_title'] == "Test Page"
|
||||
assert call_args['event'] == 'page.create'
|
||||
assert call_args['user'] == 'testuser'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_page_update_calls_process_wiki_page_change(self, listener):
|
||||
"""Test that UPDATE events call process_wiki_page_change."""
|
||||
mock_page = MagicMock()
|
||||
mock_page.title = "Updated Page"
|
||||
|
||||
with patch('src.routers.webhooks.process_wiki_page_change', new_callable=AsyncMock) as mock_process, \
|
||||
patch('src.core.dependencies.get_wiki_service') as mock_wiki_service_factory, \
|
||||
patch('src.services.wiki_change_listener.get_ingestion_service'):
|
||||
|
||||
mock_wiki_service = AsyncMock()
|
||||
mock_wiki_service.get_page.return_value = mock_page
|
||||
mock_wiki_service_factory.return_value = mock_wiki_service
|
||||
|
||||
await listener._process_page_change(
|
||||
page_id=456,
|
||||
event='page.update',
|
||||
user='testuser'
|
||||
)
|
||||
|
||||
mock_process.assert_called_once()
|
||||
call_args = mock_process.call_args[1]
|
||||
assert call_args['page_id'] == 456
|
||||
assert call_args['page_title'] == "Updated Page"
|
||||
assert call_args['event'] == 'page.update'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_lifecycle(self, listener, mock_settings):
|
||||
"""Test listener connection start and stop lifecycle."""
|
||||
mock_connection = AsyncMock()
|
||||
|
||||
with patch('src.services.wiki_change_listener.asyncpg.connect', return_value=mock_connection) as mock_connect:
|
||||
# Start listener
|
||||
await listener.start()
|
||||
|
||||
# Verify connection was established with correct parameters
|
||||
mock_connect.assert_called_once_with(
|
||||
host=mock_settings.wikijs_db_host,
|
||||
port=mock_settings.wikijs_db_port,
|
||||
user=mock_settings.wikijs_db_user,
|
||||
password=mock_settings.wikijs_db_password,
|
||||
database=mock_settings.wikijs_db_name
|
||||
)
|
||||
|
||||
# Verify listener was added
|
||||
mock_connection.add_listener.assert_called_once_with(
|
||||
'wiki_page_changes',
|
||||
listener._handle_notification
|
||||
)
|
||||
|
||||
assert listener.running is True
|
||||
assert listener.connection == mock_connection
|
||||
|
||||
# Stop listener
|
||||
await listener.stop()
|
||||
|
||||
# Verify listener was removed and connection closed
|
||||
mock_connection.remove_listener.assert_called_once()
|
||||
mock_connection.close.assert_called_once()
|
||||
assert listener.running is False
|
||||
|
||||
|
||||
class TestLoopPreventionScenarios:
|
||||
"""Integration tests for loop prevention scenarios."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_loop_prevention_flow(self, listener):
|
||||
"""
|
||||
Test complete loop prevention flow:
|
||||
1. User edits page -> Processes
|
||||
2. Entity linking updates page (as automated user) -> Filtered
|
||||
3. Rapid duplicate edits -> Debounced
|
||||
"""
|
||||
mock_connection = AsyncMock()
|
||||
|
||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
||||
# 1. User edit - should process
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'UPDATE:100:user@example.com'
|
||||
)
|
||||
assert mock_process.call_count == 1
|
||||
|
||||
mock_process.reset_mock()
|
||||
|
||||
# 2. Automated edit (entity linking) - should be filtered
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'UPDATE:100:librarian@schweitz.net'
|
||||
)
|
||||
assert mock_process.call_count == 0
|
||||
|
||||
# 3. Rapid duplicate from same user - should be debounced
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'UPDATE:100:user@example.com'
|
||||
)
|
||||
assert mock_process.call_count == 0 # Debounced
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_pages_not_debounced(self, listener):
|
||||
"""Test that edits to different pages are not debounced."""
|
||||
mock_connection = AsyncMock()
|
||||
|
||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
||||
# Edit page 100
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'UPDATE:100:user@example.com'
|
||||
)
|
||||
assert mock_process.call_count == 1
|
||||
|
||||
# Edit page 200 immediately - should NOT be debounced
|
||||
await listener._handle_notification(
|
||||
mock_connection, 1234, 'wiki_page_changes',
|
||||
'UPDATE:200:user@example.com'
|
||||
)
|
||||
assert mock_process.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestWikiChangeListenerIntegration:
|
||||
"""
|
||||
Integration tests (require actual PostgreSQL connection).
|
||||
These tests are marked with @pytest.mark.integration and skipped by default.
|
||||
Run with: pytest -m integration
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_database_connection(self):
|
||||
"""Test connection to real PostgreSQL database (requires setup)."""
|
||||
pytest.skip("Requires actual PostgreSQL setup with triggers")
|
||||
|
||||
listener = WikiChangeListener()
|
||||
try:
|
||||
await listener.start()
|
||||
assert listener.running is True
|
||||
assert listener.connection is not None
|
||||
finally:
|
||||
await listener.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_notification_handling(self):
|
||||
"""Test handling real NOTIFY events from PostgreSQL."""
|
||||
pytest.skip("Requires actual PostgreSQL setup with triggers")
|
||||
|
||||
# This would test actual pg_notify() calls from triggers
|
||||
# and verify the listener receives and processes them
|
||||
Reference in New Issue
Block a user