""" 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 field.""" request = WikiSmartCreateRequest(topic="Docker containers") assert request.topic == "Docker containers" assert request.path is None assert request.tags == [] assert request.user is None assert request.include_web_research is True assert request.include_wiki_search is True 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" ) 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/" ) 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"] ) 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", " ", ""] ) 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"] ) 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 only topic is required.""" request = WikiSmartCreateRequest(topic="Minimal test") assert request.topic == "Minimal test" assert request.include_web_research is True # default assert request.include_wiki_search is True # default