105 findings to zero. Most were mechanical — 67 unused imports, and assorted
f-strings without placeholders. Three groups needed a decision.
The 15 F821 "undefined name" were forward references, not runtime errors. Each
annotation is quoted — `-> "WikiService"`, `Optional["IngestionService"]` — with
the real import inside the function body to break an import cycle. A quoted
annotation is never evaluated, so the code ran; the names were simply
unresolvable to any checker. They now have a TYPE_CHECKING block, which costs
nothing at import time and keeps the cycle broken.
The 6 E402 split two ways. `import secrets`, `Security`, `Request` and
`HTTPBearer` in dependencies.py had drifted below several hundred lines of
factory functions for no reason — stdlib and fastapi, no cycle to avoid — and
moved up. The other three are deliberate and now say so: the VectorService and
GraphService aliases import back into dependencies.py, and main.py's routers
expect a configured app, so both must stay put.
Bare `except:` narrowed to `except Exception:` in three places, which stops them
swallowing KeyboardInterrupt and SystemExit.
The 5 unused locals were all genuinely dead. One is worth naming rather than
fixing: qdrant_client.delete()'s return value was bound and never read, so a
failed delete is indistinguishable from a successful one — the assignment is
gone, but nothing checks the status either way and that has not changed here.
`timing = {}` in _retrieve_parallel looked like it might mean the reported
per-leg timings were always zero; traced, and they come from output["timing"],
so the local was only vestigial.
426 passed, 29 skipped, unchanged. The app imports and the service aliases still
resolve, which is the check that mattered after moving imports in
dependencies.py.
The gate still prints "not gated here yet: test (T-56)" — lint is green, tests
remain unwired, and that is left visible rather than silently absent.
Co-Authored-By: Claude <noreply@anthropic.com>
562 lines
20 KiB
Python
562 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."""
|
|
|
|
# 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
|