Remove the implicit jpmschweitzer default tenant (DEFAULT_USER) from src/core/multi_tenancy.py and every endpoint and request model that inherited it (~40 endpoints across /query, /wiki, /vector, /graph, /ingest, /volatile, /documents, /stats, /rag). - Add validate_required_user() + RequiredUser pydantic type in multi_tenancy and a shared require_user FastAPI dependency (RequiredUserQuery) that rejects missing, empty, and whitespace-only users with 422, following the /maintenance/* pattern. - Wiki page create / smart-create / dossier request models now require user (no fallback in wiki_service). - /maintenance/cleanup/test-data derives the tenant from the page path instead of using the production tenant collection. - Wiki.js change listener skips changes when no tenant user can be derived from the notification email instead of defaulting to the production tenant. - Consolidation service internal helpers no longer default to the production tenant. - Tool catalog marks user as required with honest descriptions. - OpenAPI descriptions updated honestly; CHANGELOG notes that callers (tatlock, Scheduler ingest tasks) must now send explicit user. - Offline tests: 422 coverage for query/body endpoints, required-user validator tests; updated legacy tests that assumed a default tenant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
564 lines
20 KiB
Python
564 lines
20 KiB
Python
"""
|
|
Tests for Smart Page Creation functionality.
|
|
|
|
Tests the new smart-create feature including:
|
|
- WikiSmartCreateRequest/Response models
|
|
- smart_create_page() method in WikiService
|
|
- Bidirectional entity linking utilities
|
|
- POST /wiki/pages/smart-create endpoint
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from src.models.wiki import (
|
|
WikiSmartCreateRequest,
|
|
WikiSmartCreateResponse,
|
|
WikiPage
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Model Tests
|
|
# =============================================================================
|
|
|
|
class TestWikiSmartCreateRequest:
|
|
"""Tests for WikiSmartCreateRequest model validation."""
|
|
|
|
def test_minimal_request(self):
|
|
"""Test request with only required fields (topic AND user)."""
|
|
request = WikiSmartCreateRequest(topic="Docker containers", user="llm_tester")
|
|
assert request.topic == "Docker containers"
|
|
assert request.path is None
|
|
assert request.tags == []
|
|
assert request.user == "llm_tester"
|
|
assert request.include_web_research is True
|
|
assert request.include_wiki_search is True
|
|
|
|
def test_user_is_required(self):
|
|
"""A request without an explicit user must be rejected."""
|
|
with pytest.raises(ValueError):
|
|
WikiSmartCreateRequest(topic="Docker containers")
|
|
|
|
def test_full_request(self):
|
|
"""Test request with all fields."""
|
|
request = WikiSmartCreateRequest(
|
|
topic="Kubernetes orchestration",
|
|
path="/technology/kubernetes",
|
|
tags=["devops", "containers"],
|
|
user="testuser",
|
|
include_web_research=False,
|
|
include_wiki_search=True
|
|
)
|
|
assert request.topic == "Kubernetes orchestration"
|
|
assert request.path == "/technology/kubernetes"
|
|
# Tags are deduplicated via set, so order is not guaranteed
|
|
assert set(request.tags) == {"devops", "containers"}
|
|
assert request.user == "testuser"
|
|
assert request.include_web_research is False
|
|
assert request.include_wiki_search is True
|
|
|
|
def test_topic_min_length(self):
|
|
"""Test that topic requires at least 1 character."""
|
|
with pytest.raises(ValueError):
|
|
WikiSmartCreateRequest(topic="")
|
|
|
|
def test_topic_max_length(self):
|
|
"""Test that topic is limited to 500 characters."""
|
|
long_topic = "x" * 501
|
|
with pytest.raises(ValueError):
|
|
WikiSmartCreateRequest(topic=long_topic)
|
|
|
|
def test_path_validation_adds_leading_slash(self):
|
|
"""Test that path without leading slash gets one added."""
|
|
request = WikiSmartCreateRequest(
|
|
topic="Test",
|
|
path="technology/test",
|
|
user="llm_tester"
|
|
)
|
|
assert request.path == "/technology/test"
|
|
|
|
def test_path_validation_removes_trailing_slash(self):
|
|
"""Test that trailing slash is removed."""
|
|
request = WikiSmartCreateRequest(
|
|
topic="Test",
|
|
path="/technology/test/",
|
|
user="llm_tester"
|
|
)
|
|
assert request.path == "/technology/test"
|
|
|
|
def test_tags_deduplication(self):
|
|
"""Test that duplicate tags are removed."""
|
|
request = WikiSmartCreateRequest(
|
|
topic="Test",
|
|
tags=["devops", "devops", "containers", "devops"],
|
|
user="llm_tester"
|
|
)
|
|
assert len(request.tags) == 2
|
|
assert "devops" in request.tags
|
|
assert "containers" in request.tags
|
|
|
|
def test_tags_whitespace_cleanup(self):
|
|
"""Test that tag whitespace is cleaned."""
|
|
request = WikiSmartCreateRequest(
|
|
topic="Test",
|
|
tags=[" devops ", "containers", " ", ""],
|
|
user="llm_tester"
|
|
)
|
|
assert "devops" in request.tags
|
|
assert "containers" in request.tags
|
|
assert "" not in request.tags
|
|
assert " " not in request.tags
|
|
|
|
|
|
class TestWikiSmartCreateResponse:
|
|
"""Tests for WikiSmartCreateResponse model."""
|
|
|
|
def test_response_structure(self):
|
|
"""Test response model with all fields."""
|
|
page = WikiPage(
|
|
id=123,
|
|
path="/users/test/technology/docker",
|
|
title="Docker",
|
|
content="# Docker\n\nContent here",
|
|
tags=["technology"],
|
|
is_published=True,
|
|
created_at="2024-01-15T10:00:00Z",
|
|
updated_at="2024-01-15T10:00:00Z"
|
|
)
|
|
|
|
response = WikiSmartCreateResponse(
|
|
page=page,
|
|
research_summary={
|
|
"wiki_results": 3,
|
|
"web_results": 5,
|
|
"graph_entities": 2
|
|
},
|
|
sources_used=8,
|
|
search_id="test-uuid-123",
|
|
entity_linking={
|
|
"forward_links": 4,
|
|
"backward_links": 2,
|
|
"pages_updated": 1
|
|
}
|
|
)
|
|
|
|
assert response.page.id == 123
|
|
assert response.sources_used == 8
|
|
assert response.research_summary["wiki_results"] == 3
|
|
assert response.entity_linking["forward_links"] == 4
|
|
|
|
def test_response_default_entity_linking(self):
|
|
"""Test that entity_linking defaults to empty dict."""
|
|
page = WikiPage(
|
|
id=1,
|
|
path="/test",
|
|
title="Test",
|
|
content="Content",
|
|
tags=[],
|
|
is_published=True,
|
|
created_at="2024-01-15T10:00:00Z",
|
|
updated_at="2024-01-15T10:00:00Z"
|
|
)
|
|
|
|
response = WikiSmartCreateResponse(
|
|
page=page,
|
|
research_summary={},
|
|
sources_used=0
|
|
)
|
|
|
|
assert response.entity_linking == {}
|
|
assert response.search_id is None
|
|
|
|
|
|
# =============================================================================
|
|
# WikiService.smart_create_page Tests
|
|
# =============================================================================
|
|
|
|
class TestSmartCreatePage:
|
|
"""Tests for WikiService.smart_create_page method."""
|
|
|
|
@pytest.fixture
|
|
def mock_hybrid_rag_service(self):
|
|
"""Mock HybridRAG service."""
|
|
service = AsyncMock()
|
|
|
|
# Create mock response
|
|
mock_response = MagicMock()
|
|
mock_response.total_results = 5
|
|
mock_response.search_id = "search-123"
|
|
mock_response.results = [
|
|
MagicMock(
|
|
source_type="vector",
|
|
title="Existing Docker Page",
|
|
url=None,
|
|
page_path="users/testuser/docker-basics",
|
|
content="Docker is a containerization platform...",
|
|
related_dossiers=["containers"]
|
|
),
|
|
MagicMock(
|
|
source_type="web",
|
|
title="Docker Documentation",
|
|
url="https://docs.docker.com",
|
|
page_path=None,
|
|
content="Official Docker documentation...",
|
|
related_dossiers=None
|
|
)
|
|
]
|
|
mock_response.keywords = MagicMock()
|
|
mock_response.keywords.core_keywords = ["docker", "containers", "virtualization"]
|
|
mock_response.timing = MagicMock()
|
|
mock_response.timing.total_ms = 1500
|
|
|
|
service.search = AsyncMock(return_value=mock_response)
|
|
return service
|
|
|
|
@pytest.fixture
|
|
def mock_wiki_page_writer(self):
|
|
"""Mock WikiPageWriter."""
|
|
writer = AsyncMock()
|
|
writer.create_page = AsyncMock(return_value="# Docker Containers\n\n## Overview\n\nGenerated content about Docker...")
|
|
return writer
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_smart_create_basic(
|
|
self,
|
|
mock_hybrid_rag_service,
|
|
mock_wiki_page_writer
|
|
):
|
|
"""Test basic smart page creation flow."""
|
|
from src.services.wiki_service import WikiService
|
|
|
|
mock_wiki_client = AsyncMock()
|
|
wiki_service = WikiService(mock_wiki_client)
|
|
|
|
# Mock the create_page method on the service itself
|
|
mock_page = WikiPage(
|
|
id=42,
|
|
path="/users/testuser/technology/docker",
|
|
title="Docker containers",
|
|
content="# Docker\n\nGenerated content",
|
|
tags=["technology"],
|
|
is_published=True,
|
|
created_at="2024-01-15T10:00:00Z",
|
|
updated_at="2024-01-15T10:00:00Z"
|
|
)
|
|
|
|
with patch.object(wiki_service, 'create_page', new_callable=AsyncMock) as mock_create:
|
|
mock_create.return_value = mock_page
|
|
|
|
page, research_data = await wiki_service.smart_create_page(
|
|
topic="Docker containers",
|
|
user="testuser",
|
|
path="/technology/docker",
|
|
tags=["technology"],
|
|
hybrid_rag_service=mock_hybrid_rag_service,
|
|
wiki_page_writer=mock_wiki_page_writer,
|
|
include_web=True,
|
|
include_wiki=True
|
|
)
|
|
|
|
# Verify HybridRAG was called
|
|
mock_hybrid_rag_service.search.assert_called_once()
|
|
|
|
# Verify WikiPageWriter was called
|
|
mock_wiki_page_writer.create_page.assert_called_once()
|
|
|
|
# Verify page was created
|
|
mock_create.assert_called_once()
|
|
|
|
# Verify research data
|
|
assert "research_summary" in research_data
|
|
assert "sources_used" in research_data
|
|
assert "search_id" in research_data
|
|
assert research_data["search_id"] == "search-123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_smart_create_auto_generates_path(
|
|
self,
|
|
mock_hybrid_rag_service,
|
|
mock_wiki_page_writer
|
|
):
|
|
"""Test that path is auto-generated from topic when not provided."""
|
|
from src.services.wiki_service import WikiService
|
|
|
|
mock_wiki_client = AsyncMock()
|
|
wiki_service = WikiService(mock_wiki_client)
|
|
|
|
mock_page = WikiPage(
|
|
id=42,
|
|
path="/users/testuser/tutorials/docker-compose-tutorial",
|
|
title="Docker Compose Tutorial",
|
|
content="# Docker Compose\n\nContent",
|
|
tags=["tutorials"],
|
|
is_published=True,
|
|
created_at="2024-01-15T10:00:00Z",
|
|
updated_at="2024-01-15T10:00:00Z"
|
|
)
|
|
|
|
with patch.object(wiki_service, 'create_page', new_callable=AsyncMock) as mock_create:
|
|
mock_create.return_value = mock_page
|
|
|
|
await wiki_service.smart_create_page(
|
|
topic="Docker Compose Tutorial",
|
|
user="testuser",
|
|
path=None, # No path provided
|
|
tags=["tutorials"],
|
|
hybrid_rag_service=mock_hybrid_rag_service,
|
|
wiki_page_writer=mock_wiki_page_writer
|
|
)
|
|
|
|
# Check that create_page was called
|
|
mock_create.assert_called_once()
|
|
# The WikiPageCreate passed should have auto-generated path
|
|
call_args = mock_create.call_args[0][0] # First positional arg
|
|
assert "docker-compose-tutorial" in call_args.path.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_smart_create_respects_web_flag(
|
|
self,
|
|
mock_hybrid_rag_service,
|
|
mock_wiki_page_writer
|
|
):
|
|
"""Test that include_web flag is passed to HybridRAG."""
|
|
from src.services.wiki_service import WikiService
|
|
|
|
mock_wiki_client = AsyncMock()
|
|
wiki_service = WikiService(mock_wiki_client)
|
|
|
|
mock_page = WikiPage(
|
|
id=1,
|
|
path="/test",
|
|
title="Test",
|
|
content="Content",
|
|
tags=[],
|
|
is_published=True,
|
|
created_at="2024-01-15T10:00:00Z",
|
|
updated_at="2024-01-15T10:00:00Z"
|
|
)
|
|
|
|
with patch.object(wiki_service, 'create_page', new_callable=AsyncMock) as mock_create:
|
|
mock_create.return_value = mock_page
|
|
|
|
await wiki_service.smart_create_page(
|
|
topic="Test",
|
|
user="testuser",
|
|
path="/test",
|
|
tags=[],
|
|
hybrid_rag_service=mock_hybrid_rag_service,
|
|
wiki_page_writer=mock_wiki_page_writer,
|
|
include_web=False,
|
|
include_wiki=True
|
|
)
|
|
|
|
# Check HybridRAG config
|
|
call_args = mock_hybrid_rag_service.search.call_args
|
|
config = call_args[1]["config"]
|
|
assert config.enable_web is False
|
|
assert config.enable_vector is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_smart_create_counts_sources(
|
|
self,
|
|
mock_hybrid_rag_service,
|
|
mock_wiki_page_writer
|
|
):
|
|
"""Test that sources are counted correctly."""
|
|
from src.services.wiki_service import WikiService
|
|
|
|
mock_wiki_client = AsyncMock()
|
|
wiki_service = WikiService(mock_wiki_client)
|
|
|
|
mock_page = WikiPage(
|
|
id=1,
|
|
path="/test",
|
|
title="Test",
|
|
content="Content",
|
|
tags=[],
|
|
is_published=True,
|
|
created_at="2024-01-15T10:00:00Z",
|
|
updated_at="2024-01-15T10:00:00Z"
|
|
)
|
|
|
|
with patch.object(wiki_service, 'create_page', new_callable=AsyncMock) as mock_create:
|
|
mock_create.return_value = mock_page
|
|
|
|
page, research_data = await wiki_service.smart_create_page(
|
|
topic="Test",
|
|
user="testuser",
|
|
path="/test",
|
|
tags=[],
|
|
hybrid_rag_service=mock_hybrid_rag_service,
|
|
wiki_page_writer=mock_wiki_page_writer
|
|
)
|
|
|
|
# Should have 2 sources (1 wiki + 1 web from mock)
|
|
assert research_data["sources_used"] == 2
|
|
assert research_data["research_summary"]["wiki_results"] == 1
|
|
assert research_data["research_summary"]["web_results"] == 1
|
|
|
|
|
|
# =============================================================================
|
|
# Entity Linking Utils Tests
|
|
# =============================================================================
|
|
|
|
class TestBidirectionalEntityLinking:
|
|
"""Tests for entity_linking_utils.apply_bidirectional_entity_linking."""
|
|
|
|
@pytest.fixture
|
|
def mock_neo4j_client(self):
|
|
"""Mock Neo4j client."""
|
|
client = AsyncMock()
|
|
client.execute_query = AsyncMock(return_value=[])
|
|
return client
|
|
|
|
@pytest.fixture
|
|
def mock_wiki_service(self):
|
|
"""Mock WikiService."""
|
|
service = AsyncMock()
|
|
return service
|
|
|
|
@pytest.fixture
|
|
def mock_ingestion_service(self):
|
|
"""Mock IngestionService."""
|
|
service = AsyncMock()
|
|
return service
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_link_counts(
|
|
self,
|
|
mock_neo4j_client,
|
|
mock_wiki_service,
|
|
mock_ingestion_service
|
|
):
|
|
"""Test that function returns proper link count structure."""
|
|
from src.services.entity_linking_utils import apply_bidirectional_entity_linking
|
|
|
|
# Patch at the import location within the module
|
|
with patch('src.routers.entity_linking.link_entities_in_page') as mock_link:
|
|
mock_result = MagicMock()
|
|
mock_result.content_links_added = 3
|
|
mock_link.return_value = mock_result
|
|
|
|
with patch('src.core.dependencies.get_graph_service'):
|
|
with patch('src.core.dependencies.get_ingestion_service', return_value=mock_ingestion_service):
|
|
result = await apply_bidirectional_entity_linking(
|
|
page_id=42,
|
|
page_title="Docker",
|
|
user="testuser",
|
|
neo4j_client=mock_neo4j_client,
|
|
wiki_service=mock_wiki_service,
|
|
ingestion_service=mock_ingestion_service
|
|
)
|
|
|
|
assert "forward_links" in result
|
|
assert "backward_links" in result
|
|
assert "pages_updated" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handles_no_reverse_references(
|
|
self,
|
|
mock_neo4j_client,
|
|
mock_wiki_service,
|
|
mock_ingestion_service
|
|
):
|
|
"""Test graceful handling when no reverse references found."""
|
|
from src.services.entity_linking_utils import apply_bidirectional_entity_linking
|
|
|
|
# No reverse references
|
|
mock_neo4j_client.execute_query = AsyncMock(return_value=[])
|
|
|
|
with patch('src.routers.entity_linking.link_entities_in_page') as mock_link:
|
|
mock_result = MagicMock()
|
|
mock_result.content_links_added = 2
|
|
mock_link.return_value = mock_result
|
|
|
|
with patch('src.core.dependencies.get_graph_service'):
|
|
with patch('src.core.dependencies.get_ingestion_service', return_value=mock_ingestion_service):
|
|
result = await apply_bidirectional_entity_linking(
|
|
page_id=42,
|
|
page_title="NewEntity",
|
|
user="testuser",
|
|
neo4j_client=mock_neo4j_client,
|
|
wiki_service=mock_wiki_service,
|
|
ingestion_service=mock_ingestion_service
|
|
)
|
|
|
|
assert result["backward_links"] == 0
|
|
assert result["pages_updated"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handles_errors_gracefully(
|
|
self,
|
|
mock_neo4j_client,
|
|
mock_wiki_service,
|
|
mock_ingestion_service
|
|
):
|
|
"""Test that errors don't crash the function."""
|
|
from src.services.entity_linking_utils import apply_bidirectional_entity_linking
|
|
|
|
with patch('src.routers.entity_linking.link_entities_in_page') as mock_link:
|
|
mock_link.side_effect = Exception("Test error")
|
|
|
|
with patch('src.core.dependencies.get_graph_service'):
|
|
with patch('src.core.dependencies.get_ingestion_service', return_value=mock_ingestion_service):
|
|
result = await apply_bidirectional_entity_linking(
|
|
page_id=42,
|
|
page_title="Test",
|
|
user="testuser",
|
|
neo4j_client=mock_neo4j_client,
|
|
wiki_service=mock_wiki_service,
|
|
ingestion_service=mock_ingestion_service
|
|
)
|
|
|
|
# Should return zeros, not raise
|
|
assert result["forward_links"] == 0
|
|
assert result["backward_links"] == 0
|
|
assert result["pages_updated"] == 0
|
|
|
|
|
|
# =============================================================================
|
|
# Endpoint Tests
|
|
# =============================================================================
|
|
|
|
class TestSmartCreateEndpoint:
|
|
"""Tests for POST /wiki/pages/smart-create endpoint."""
|
|
|
|
@pytest.fixture
|
|
def mock_clients(self):
|
|
"""Create all mock clients needed for the endpoint."""
|
|
return {
|
|
"wiki_client": AsyncMock(),
|
|
"neo4j_client": AsyncMock(),
|
|
"qdrant_client": MagicMock(),
|
|
"ollama_client": AsyncMock(),
|
|
"searxng_client": AsyncMock()
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_endpoint_returns_201(self, mock_clients):
|
|
"""Test that successful creation returns 201 status."""
|
|
from fastapi.testclient import TestClient
|
|
from unittest.mock import patch
|
|
|
|
# This test would require more setup with FastAPI TestClient
|
|
# For now, we test the model validation
|
|
request = WikiSmartCreateRequest(
|
|
topic="Test Topic",
|
|
tags=["test"],
|
|
user="llm_tester"
|
|
)
|
|
assert request.topic == "Test Topic"
|
|
|
|
def test_request_validation_rejects_empty_topic(self):
|
|
"""Test that empty topic is rejected."""
|
|
with pytest.raises(ValueError):
|
|
WikiSmartCreateRequest(topic="")
|
|
|
|
def test_request_accepts_minimal_input(self):
|
|
"""Test that topic and user are the only required fields."""
|
|
request = WikiSmartCreateRequest(topic="Minimal test", user="llm_tester")
|
|
assert request.topic == "Minimal test"
|
|
assert request.include_web_research is True # default
|
|
assert request.include_wiki_search is True # default
|