diff --git a/tests/integration/test_qdrant_collection_creation.py b/tests/integration/test_qdrant_collection_creation.py index 3837efd..67b4f62 100644 --- a/tests/integration/test_qdrant_collection_creation.py +++ b/tests/integration/test_qdrant_collection_creation.py @@ -10,12 +10,21 @@ These tests validate that: from unittest.mock import Mock import pytest +from qdrant_client.models import VectorParams from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client pytestmark = pytest.mark.integration +def get_vector_params(collection_info) -> VectorParams: + """Get vector params from collection info, handling named vectors format.""" + vectors = collection_info.config.params.vectors + if isinstance(vectors, dict): + return vectors["dense"] + return vectors + + @pytest.fixture(autouse=True) async def reset_singleton(): """Reset the global Qdrant client singleton between tests.""" @@ -75,7 +84,7 @@ async def test_collection_auto_created_on_first_access(monkeypatch): # Verify collection has correct dimensions collection_info = await client.get_collection(collection_name) - assert collection_info.config.params.vectors.size == 384 + assert get_vector_params(collection_info).size == 384 @pytest.mark.integration @@ -127,7 +136,7 @@ async def test_existing_collection_reused(monkeypatch): # Verify dimensions unchanged collection_info = await client2.get_collection(collection_name) - assert collection_info.config.params.vectors.size == 384 + assert get_vector_params(collection_info).size == 384 @pytest.mark.integration @@ -164,7 +173,7 @@ async def test_dimension_mismatch_detected(monkeypatch, tmp_path): # Verify collection created collection_info = await client1.get_collection(collection_name) - assert collection_info.config.params.vectors.size == 384 + assert get_vector_params(collection_info).size == 384 # Close client1 to release file lock await client1.close() @@ -248,12 +257,10 @@ async def test_collection_name_generation(monkeypatch): mock_settings = Settings( qdrant_location=":memory:", ollama_embedding_model="test-model", + otel_service_name="test-deployment", vector_sync_enabled=False, ) - # Mock deployment ID - monkeypatch.setenv("MCP_DEPLOYMENT_ID", "test-deployment") - monkeypatch.setattr( "nextcloud_mcp_server.vector.qdrant_client.get_settings", lambda: mock_settings ) @@ -319,4 +326,4 @@ async def test_collection_uses_cosine_distance(monkeypatch): from qdrant_client.models import Distance - assert collection_info.config.params.vectors.distance == Distance.COSINE + assert get_vector_params(collection_info).distance == Distance.COSINE diff --git a/tests/integration/test_rag.py b/tests/integration/test_rag.py index a9ba160..54996a2 100644 --- a/tests/integration/test_rag.py +++ b/tests/integration/test_rag.py @@ -51,6 +51,14 @@ logger = logging.getLogger(__name__) DEFAULT_MANUAL_PATH = "Nextcloud Manual.pdf" +async def require_vector_sync_tools(nc_mcp_client): + """Skip test if vector sync tools are not available.""" + tools = await nc_mcp_client.list_tools() + tool_names = [t.name for t in tools.tools] + if "nc_get_vector_sync_status" not in tool_names: + pytest.skip("Vector sync tools not available (VECTOR_SYNC_ENABLED not set)") + + async def llm_judge( provider: Provider, ground_truth: str, @@ -116,6 +124,8 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client): Environment Variables: RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: Nextcloud Manual.pdf) """ + await require_vector_sync_tools(nc_mcp_client) + manual_path = os.getenv("RAG_MANUAL_PATH", DEFAULT_MANUAL_PATH) logger.info(f"Setting up indexed manual PDF: {manual_path}") @@ -152,7 +162,7 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client): ) if not result.isError: - content = result.structuredContent or {} + content = json.loads(result.content[0].text) if result.content else {} indexed = content.get("indexed_count", 0) pending = content.get("pending_count", 1) @@ -248,7 +258,7 @@ async def test_semantic_search_retrieval( ) assert result.isError is False, f"Tool call failed: {result}" - data = result.structuredContent + data = json.loads(result.content[0].text) # Verify we got results assert data["success"] is True @@ -295,7 +305,7 @@ async def test_semantic_search_answer_with_sampling( ) assert result.isError is False, f"Tool call failed: {result}" - data = result.structuredContent + data = json.loads(result.content[0].text) # Verify response structure assert data["success"] is True @@ -369,7 +379,7 @@ async def test_retrieval_quality_all_queries( ) assert result.isError is False - data = result.structuredContent + data = json.loads(result.content[0].text) assert data["total_found"] >= min_expected_results, ( f"Query '{query}' returned {data['total_found']} results, " @@ -393,7 +403,7 @@ async def test_no_results_for_unrelated_query(nc_mcp_client, indexed_manual_pdf) ) assert result.isError is False - data = result.structuredContent + data = json.loads(result.content[0].text) # Should have few or no high-scoring results # Low score threshold means we might get some results, but they should be low quality diff --git a/tests/integration/test_sampling.py b/tests/integration/test_sampling.py index 6112a01..cb7ff5f 100644 --- a/tests/integration/test_sampling.py +++ b/tests/integration/test_sampling.py @@ -13,6 +13,7 @@ Note: These tests require VECTOR_SYNC_ENABLED=true and a configured vector database with indexed test data. """ +import json from unittest.mock import MagicMock import pytest @@ -21,6 +22,14 @@ from mcp.types import CreateMessageResult, TextContent pytestmark = pytest.mark.integration +async def require_vector_sync_tools(nc_mcp_client): + """Skip test if vector sync tools are not available.""" + tools = await nc_mcp_client.list_tools() + tool_names = [t.name for t in tools.tools] + if "nc_get_vector_sync_status" not in tool_names: + pytest.skip("Vector sync tools not available (VECTOR_SYNC_ENABLED not set)") + + @pytest.fixture def mock_sampling_result(): """Mock successful sampling result from MCP client.""" @@ -55,13 +64,15 @@ async def test_semantic_search_answer_successful_sampling( 4. Mock ctx.session.create_message to return answer 5. Verify response contains generated answer and sources """ + await require_vector_sync_tools(nc_mcp_client) + # Get initial indexed count before creating note import asyncio initial_sync = await nc_mcp_client.call_tool( "nc_get_vector_sync_status", arguments={} ) - initial_indexed_count = initial_sync.structuredContent["indexed_count"] + initial_indexed_count = json.loads(initial_sync.content[0].text)["indexed_count"] print(f"Initial indexed count: {initial_indexed_count}") # Create a note with content about Python async @@ -90,7 +101,7 @@ Avoid blocking operations in async code.""", sync_status = await nc_mcp_client.call_tool( "nc_get_vector_sync_status", arguments={} ) - status_data = sync_status.structuredContent + status_data = json.loads(sync_status.content[0].text) print( f"Sync status at {waited}s: indexed={status_data['indexed_count']}, pending={status_data['pending_count']}, status={status_data['status']}" @@ -135,7 +146,7 @@ Avoid blocking operations in async code.""", assert call_result.isError is False, ( f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}" ) - result = call_result.structuredContent + result = json.loads(call_result.content[0].text) # Verify response structure assert result is not None @@ -179,6 +190,8 @@ async def test_semantic_search_answer_no_results(nc_mcp_client): 2. Verify response indicates no documents found 3. Verify no sampling call was made (no sources to base answer on) """ + await require_vector_sync_tools(nc_mcp_client) + call_result = await nc_mcp_client.call_tool( "nc_semantic_search_answer", arguments={ @@ -192,7 +205,7 @@ async def test_semantic_search_answer_no_results(nc_mcp_client): assert call_result.isError is False, ( f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}" ) - result = call_result.structuredContent + result = json.loads(call_result.content[0].text) # Should get "no documents found" message assert result is not None @@ -214,6 +227,8 @@ async def test_semantic_search_answer_with_limit(nc_mcp_client, temporary_note_f 3. Query with limit=2 4. Verify at most 2 sources in response """ + await require_vector_sync_tools(nc_mcp_client) + # Create multiple related notes _note1 = await temporary_note_factory( title="Python Async Part 1", @@ -242,7 +257,7 @@ async def test_semantic_search_answer_with_limit(nc_mcp_client, temporary_note_f sync_status = await nc_mcp_client.call_tool( "nc_get_vector_sync_status", arguments={} ) - status_data = sync_status.structuredContent + status_data = json.loads(sync_status.content[0].text) if status_data["status"] == "idle" and status_data["pending_count"] == 0: break @@ -265,7 +280,7 @@ async def test_semantic_search_answer_with_limit(nc_mcp_client, temporary_note_f assert call_result.isError is False, ( f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}" ) - result = call_result.structuredContent + result = json.loads(call_result.content[0].text) # Should respect limit assert len(result["sources"]) <= 2 @@ -282,6 +297,8 @@ async def test_semantic_search_answer_score_threshold( 3. Query with high threshold (0.9) 4. Verify only high-scoring results returned """ + await require_vector_sync_tools(nc_mcp_client) + _note = await temporary_note_factory( title="Exact Match Test", content="This is a very specific test document about widget manufacturing", @@ -299,7 +316,7 @@ async def test_semantic_search_answer_score_threshold( sync_status = await nc_mcp_client.call_tool( "nc_get_vector_sync_status", arguments={} ) - status_data = sync_status.structuredContent + status_data = json.loads(sync_status.content[0].text) if status_data["status"] == "idle" and status_data["pending_count"] == 0: break @@ -323,7 +340,7 @@ async def test_semantic_search_answer_score_threshold( assert call_result.isError is False, ( f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}" ) - result = call_result.structuredContent + result = json.loads(call_result.content[0].text) # Note: Semantic search scores depend on embedding model # We just verify the tool accepts the parameter @@ -345,6 +362,8 @@ async def test_semantic_search_answer_max_tokens(nc_mcp_client, temporary_note_f Note: Token limiting is enforced by the MCP client's LLM, not the server. This test just verifies the parameter is correctly passed. """ + await require_vector_sync_tools(nc_mcp_client) + _note = await temporary_note_factory( title="Long Document", content="This is a document with lots of content. " * 50, @@ -362,7 +381,7 @@ async def test_semantic_search_answer_max_tokens(nc_mcp_client, temporary_note_f sync_status = await nc_mcp_client.call_tool( "nc_get_vector_sync_status", arguments={} ) - status_data = sync_status.structuredContent + status_data = json.loads(sync_status.content[0].text) if status_data["status"] == "idle" and status_data["pending_count"] == 0: break @@ -386,7 +405,7 @@ async def test_semantic_search_answer_max_tokens(nc_mcp_client, temporary_note_f assert call_result.isError is False, ( f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}" ) - result = call_result.structuredContent + result = json.loads(call_result.content[0].text) # Should not error, even if sampling fails assert result is not None