Files
library-desk/tests/test_graph_service.py
T
jpmschweitzerandClaude Fable 5 9ceec1464a fix: WS5 hazards batch - CORS, scheduler auth, reranker dedupe, wiring, write txns
- CORS: drop allow_credentials (wildcard origin + credentials told
  browsers to attach credentials for any site); origins configurable via
  CORS_ALLOW_ORIGINS (default * is safe without credentials). Verified
  live: preflight no longer advertises access-control-allow-credentials.
- Scheduler tasks: auth moved from a plain Authorization header (which
  the Scheduler's rest_api_executor does NOT env-substitute) to its
  auth {type: bearer, token: ${LIBRARY_API_KEY}} block, substituted from
  the Scheduler's own environment at execution time. The registrar no
  longer resolves the real key client-side, so it can never be persisted
  into the scheduled_tasks.config JSONB column. Also fixed: JSON bodies
  moved from the ignored "body" key to "payload" (the executor only
  reads config["payload"], so the tasks would have POSTed empty bodies
  and failed required-user validation).
- Reranker: parsed ranking indices are deduplicated preserving first
  occurrence (an LLM answer like "3,3,1" duplicated a result).
- HybridRAG wiring consolidated into dependencies.get_hybrid_rag_service
  (now including volatile_service); the inline copies in /query/hybrid
  and /wiki/pages/smart-create are gone - smart-create previously ran
  without the volatile leg, and the singleton was unused.
- Remaining Neo4j writes (GraphService ingestion/deletes/purges/entity
  mentions, webhook rename+delete cleanup, document-sync _index_graph,
  consolidation mark-processed/add-entity) moved from auto-commit
  execute_query to execute_write managed transactions with retry.

Verified end-to-end on the local dev server as llm_tester: /query/hybrid
200 with all five legs ok (volatile now active), background persistence
landed as one transaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
2026-07-14 14:50:03 +02:00

253 lines
7.1 KiB
Python

"""
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}}])
mock.execute_write = 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 the write transaction was used
assert mock_neo4j.execute_write.called
assert result.success is True
# Find the document creation query
calls = mock_neo4j.execute_write.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_write.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
assert mock_neo4j.execute_write.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
assert mock_neo4j.execute_write.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_write.called
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])