test(library-desk): add test coverage for new features

- test_consolidation: taxonomy fetching, ingestion_service injection
- test_graph_service: Document node tags, entity-stub skipping
- test_ingestion: list_all_pages usage, batch ingestion
- test_integration: list_all_pages, get_taxonomy_structure
- test_wiki_change_listener: PostgreSQL notification handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-10 21:35:39 +01:00
co-authored by Claude Opus 4.5
parent b74b5bc2c8
commit 14d152bd64
5 changed files with 1120 additions and 12 deletions
+101 -12
View File
@@ -25,7 +25,6 @@ from src.models.consolidation import (
ConsolidationResult,
SearchQueryInfo
)
from src.config import get_settings
# Test constants
TEST_USER = "consolidation-tester"
@@ -36,8 +35,11 @@ TEST_SEARCH_ID = "test-search-123"
@pytest.fixture
def settings():
"""Get application settings."""
return get_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
@@ -60,17 +62,46 @@ def mock_ollama():
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 consolidation_service(mock_neo4j, mock_ollama, mock_wiki, settings):
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
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
)
@@ -297,6 +328,7 @@ async def test_get_web_results_empty(consolidation_service, mock_neo4j):
async def test_analyze_web_results_with_novel_info(
consolidation_service,
mock_ollama,
mock_wiki,
sample_web_results,
sample_llm_analysis
):
@@ -307,7 +339,8 @@ async def test_analyze_web_results_with_novel_info(
analysis = await consolidation_service._analyze_web_results(
query="docker orchestration",
web_results=sample_web_results,
keywords=["docker", "orchestration"]
keywords=["docker", "orchestration"],
user=TEST_USER
)
assert analysis is not None
@@ -316,6 +349,8 @@ async def test_analyze_web_results_with_novel_info(
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
@@ -337,7 +372,8 @@ async def test_analyze_web_results_no_novel_info(
analysis = await consolidation_service._analyze_web_results(
query="common topic",
web_results=sample_web_results,
keywords=[]
keywords=[],
user=TEST_USER
)
assert analysis is not None
@@ -358,7 +394,8 @@ async def test_analyze_web_results_invalid_json(
analysis = await consolidation_service._analyze_web_results(
query="test query",
web_results=sample_web_results,
keywords=[]
keywords=[],
user=TEST_USER
)
assert analysis is None
@@ -385,13 +422,65 @@ Hope this helps!"""
analysis = await consolidation_service._analyze_web_results(
query="test",
web_results=sample_web_results,
keywords=[]
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."""
@@ -563,8 +652,7 @@ async def test_consolidate_knowledge_with_errors(
@pytest.mark.asyncio
async def test_consolidation_endpoint_minimal_request(consolidation_service):
"""Test consolidation endpoint with minimal request."""
from fastapi.testclient import TestClient
from src.main import app
pytest.importorskip("fastapi") # Skip if fastapi not available
# This would require proper test client setup
# Placeholder for integration test structure
@@ -618,7 +706,8 @@ async def test_analyze_empty_web_results(consolidation_service, mock_ollama):
analysis = await consolidation_service._analyze_web_results(
query="test",
web_results=[],
keywords=[]
keywords=[],
user=TEST_USER
)
# Should still call LLM but return no novel info
@@ -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"])
@@ -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"])
@@ -187,6 +187,27 @@ class TestWikiJSIntegration:
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."""
@@ -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