Initial commit: library-desk service extraction from portainer-core
Build and Push / build (release) Successful in 36s

This commit is contained in:
2025-12-11 17:28:23 +01:00
commit 95852190ba
59 changed files with 17146 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests for Library Desk service."""
+109
View File
@@ -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)]
]
+754
View File
@@ -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"])
+484
View File
@@ -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")
+249
View File
@@ -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"])
+711
View File
@@ -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}")
+314
View File
@@ -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"])
+334
View File
@@ -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()
+115
View File
@@ -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
+435
View File
@@ -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