diff --git a/pyproject.toml b/pyproject.toml index 3cc6599..e6b1398 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ secret = "qdrant-mcp" [dependency-groups] dev = [ "fastembed>=0.7.3", + "fastmcp[mcp]>=2.12.3", "pytest>=8.4.2", "pytest-asyncio>=1.2.0", "pytest-cov>=7.0.0", diff --git a/tests/test_client/__init__.py b/tests/test_client/__init__.py new file mode 100644 index 0000000..b0d396d --- /dev/null +++ b/tests/test_client/__init__.py @@ -0,0 +1 @@ +"""Tests for the vector search client module.""" diff --git a/tests/test_client/test_client.py b/tests/test_client/test_client.py new file mode 100644 index 0000000..c2dc264 --- /dev/null +++ b/tests/test_client/test_client.py @@ -0,0 +1,259 @@ +"""Tests for the vector search client module.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from vector_search_mcp.client import Client +from vector_search_mcp.engine import Backend +from vector_search_mcp.models import Chunk, ChunkData, Condition, Match + + +@pytest.fixture +def mock_engine(): + """Create a mock engine for testing.""" + engine = AsyncMock() + engine.create_index.return_value = True + engine.upload_chunk.return_value = True + engine.semantic_search.return_value = [ + {"chunk_id": "1", "score": 0.95, "payload": {"text": "result 1"}}, + {"chunk_id": "2", "score": 0.85, "payload": {"text": "result 2"}}, + ] + return engine + + +@pytest.fixture +def sample_chunk(): + """Create a sample chunk for testing.""" + return Chunk( + id="test-chunk-1", + vector=[0.1, 0.2, 0.3, 0.4, 0.5], + payload=ChunkData( + page_content="This is a test chunk content", + filename="test_document.pdf", + page=1, + ), + ) + + +class TestClient: + """Test suite for the Client class.""" + + def test_client_initialization(self, mock_engine, monkeypatch): + """Test that Client initializes correctly with backend and collection.""" + # Mock the get_engine function + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="test_collection") + + assert client.collection == "test_collection" + assert client.engine == mock_engine + mock_get_engine.assert_called_once_with(Backend.QDRANT) + + @pytest.mark.asyncio + async def test_create_index(self, mock_engine, monkeypatch): + """Test create_index method delegates to engine.""" + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="test_collection") + result = await client.create_index(size=512) + + assert result is True + mock_engine.create_index.assert_called_once_with("test_collection", 512) + + @pytest.mark.asyncio + async def test_create_index_failure(self, mock_engine, monkeypatch): + """Test create_index method handles failure.""" + mock_engine.create_index.return_value = False + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="test_collection") + result = await client.create_index(size=256) + + assert result is False + mock_engine.create_index.assert_called_once_with("test_collection", 256) + + @pytest.mark.asyncio + async def test_upload_chunk(self, mock_engine, monkeypatch, sample_chunk): + """Test upload_chunk method delegates to engine.""" + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="documents") + result = await client.upload_chunk(sample_chunk) + + assert result is True + mock_engine.upload_chunk.assert_called_once_with("documents", sample_chunk) + + @pytest.mark.asyncio + async def test_upload_chunk_failure(self, mock_engine, monkeypatch, sample_chunk): + """Test upload_chunk method handles failure.""" + mock_engine.upload_chunk.return_value = False + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="documents") + result = await client.upload_chunk(sample_chunk) + + assert result is False + mock_engine.upload_chunk.assert_called_once_with("documents", sample_chunk) + + @pytest.mark.asyncio + async def test_semantic_search_default_parameters(self, mock_engine, monkeypatch): + """Test semantic_search with default parameters.""" + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="search_collection") + embedding = [0.1, 0.2, 0.3, 0.4, 0.5] + + result = await client.semantic_search(embedding) + + mock_engine.semantic_search.assert_called_once_with( + embedding, "search_collection", 10, None, None + ) + assert result == mock_engine.semantic_search.return_value + + @pytest.mark.asyncio + async def test_semantic_search_with_limit(self, mock_engine, monkeypatch): + """Test semantic_search with custom limit.""" + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="search_collection") + embedding = [0.1, 0.2, 0.3] + + result = await client.semantic_search(embedding, limit=5) + + mock_engine.semantic_search.assert_called_once_with( + embedding, "search_collection", 5, None, None + ) + assert result == mock_engine.semantic_search.return_value + + @pytest.mark.asyncio + async def test_semantic_search_with_conditions(self, mock_engine, monkeypatch): + """Test semantic_search with filter conditions.""" + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="filtered_collection") + embedding = [0.1, 0.2, 0.3, 0.4] + conditions = [Match(key="category", value="technology")] + + result = await client.semantic_search(embedding, conditions=conditions) + + mock_engine.semantic_search.assert_called_once_with( + embedding, "filtered_collection", 10, conditions, None + ) + assert result == mock_engine.semantic_search.return_value + + @pytest.mark.asyncio + async def test_semantic_search_with_threshold(self, mock_engine, monkeypatch): + """Test semantic_search with similarity threshold.""" + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="threshold_collection") + embedding = [0.5, 0.4, 0.3, 0.2, 0.1] + + result = await client.semantic_search(embedding, threshold=0.8) + + mock_engine.semantic_search.assert_called_once_with( + embedding, "threshold_collection", 10, None, 0.8 + ) + assert result == mock_engine.semantic_search.return_value + + @pytest.mark.asyncio + async def test_semantic_search_all_parameters(self, mock_engine, monkeypatch): + """Test semantic_search with all parameters specified.""" + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="full_params_collection") + embedding = [0.2, 0.4, 0.6, 0.8, 1.0] + conditions = [ + Match(key="status", value="published"), + Match(key="author", value="john_doe"), + ] + + result = await client.semantic_search( + embedding=embedding, + limit=3, + conditions=conditions, + threshold=0.75, + ) + + mock_engine.semantic_search.assert_called_once_with( + embedding, "full_params_collection", 3, conditions, 0.75 + ) + assert result == mock_engine.semantic_search.return_value + + def test_client_is_final(self): + """Test that Client class is marked as final.""" + from typing import get_origin + + # Check if Client is decorated with @final + assert hasattr(Client, "__final__") or Client.__dict__.get("__final__", False) + + @pytest.mark.asyncio + async def test_client_integration_workflow(self, mock_engine, monkeypatch, sample_chunk): + """Test a complete workflow: create index, upload chunk, search.""" + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="workflow_test") + + # Create index + index_result = await client.create_index(size=384) + assert index_result is True + + # Upload chunk + upload_result = await client.upload_chunk(sample_chunk) + assert upload_result is True + + # Search + search_embedding = [0.1, 0.2, 0.3, 0.4, 0.5] + search_result = await client.semantic_search(search_embedding, limit=5) + + # Verify all operations were called correctly + mock_engine.create_index.assert_called_once_with("workflow_test", 384) + mock_engine.upload_chunk.assert_called_once_with("workflow_test", sample_chunk) + mock_engine.semantic_search.assert_called_once_with( + search_embedding, "workflow_test", 5, None, None + ) + assert search_result == mock_engine.semantic_search.return_value + + @pytest.mark.asyncio + async def test_semantic_search_empty_results(self, mock_engine, monkeypatch): + """Test semantic_search when no results are found.""" + mock_engine.semantic_search.return_value = [] + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="empty_results") + embedding = [0.1, 0.2, 0.3] + + result = await client.semantic_search(embedding) + + assert result == [] + mock_engine.semantic_search.assert_called_once_with( + embedding, "empty_results", 10, None, None + ) + + def test_client_attributes_after_init(self, mock_engine, monkeypatch): + """Test that client has the expected attributes after initialization.""" + mock_get_engine = MagicMock(return_value=mock_engine) + monkeypatch.setattr("vector_search_mcp.client.get_engine", mock_get_engine) + + client = Client(backend=Backend.QDRANT, collection="attr_test") + + assert hasattr(client, "engine") + assert hasattr(client, "collection") + assert client.engine is mock_engine + assert client.collection == "attr_test" + assert hasattr(client, "create_index") + assert hasattr(client, "upload_chunk") + assert hasattr(client, "semantic_search") diff --git a/tests/test_engine/test_base_engine.py b/tests/test_engine/test_base_engine.py index 4cd6526..06270d4 100644 --- a/tests/test_engine/test_base_engine.py +++ b/tests/test_engine/test_base_engine.py @@ -237,6 +237,29 @@ class IncompleteEngine(BaseEngine[str, int, str]): # Missing transform_response, run_similarity_query, create_index, transform_chunk, run_upload_chunk + @pytest.mark.asyncio + async def test_upload_chunk_workflow(self): + """Test the complete upload_chunk workflow""" + engine = MockEngine() + from vector_search_mcp.models import Chunk, ChunkData + + chunk = Chunk( + id="test-chunk-1", + vector=[0.1, 0.2, 0.3], + payload=ChunkData( + page_content="Test content", + filename="test.pdf", + page=1 + ) + ) + + result = await engine.upload_chunk("test_index", chunk) + + # Verify the workflow called both transform_chunk and run_upload_chunk + assert result is True + # The MockEngine.run_upload_chunk should have been called with transformed chunk + + class TestAbstractMethodEnforcement: """Test that abstract methods must be implemented""" diff --git a/tests/test_engine/test_qdrant_engine.py b/tests/test_engine/test_qdrant_engine.py index 656fb8e..11f03f4 100644 --- a/tests/test_engine/test_qdrant_engine.py +++ b/tests/test_engine/test_qdrant_engine.py @@ -129,13 +129,138 @@ class TestQdrantEngine: assert all(isinstance(cond, models.FieldCondition) for cond in result.must) def test_transform_response_empty(self, qdrant_engine): - """Test transform_response with empty response""" + """Test transform_response with empty results""" response = [] - result = qdrant_engine.transform_response(response) + assert result == [] - assert isinstance(result, list) - assert len(result) == 0 + @pytest.mark.asyncio + async def test_create_index(self, qdrant_engine, mock_client): + """Test create_index method""" + mock_client.create_collection.return_value = True + + result = await qdrant_engine.create_index("test_collection", 384) + + assert result is True + mock_client.create_collection.assert_called_once_with( + collection_name="test_collection", + vectors_config=models.VectorParams( + size=384, distance=models.Distance.COSINE + ), + ) + + @pytest.mark.asyncio + async def test_create_index_failure(self, qdrant_engine, mock_client): + """Test create_index method when it fails""" + mock_client.create_collection.side_effect = Exception("Collection creation failed") + + with pytest.raises(Exception, match="Collection creation failed"): + await qdrant_engine.create_index("failing_collection", 512) + + def test_transform_chunk(self, qdrant_engine): + """Test transform_chunk method""" + from vector_search_mcp.models import Chunk, ChunkData + + chunk = Chunk( + id="test-chunk-1", + vector=[0.1, 0.2, 0.3, 0.4, 0.5], + payload=ChunkData( + page_content="This is test content", + filename="test_doc.pdf", + page=42 + ) + ) + + result = qdrant_engine.transform_chunk(chunk) + + assert isinstance(result, models.PointStruct) + assert result.id == "test-chunk-1" + assert result.vector == [0.1, 0.2, 0.3, 0.4, 0.5] + assert result.payload == { + "page_content": "This is test content", + "filename": "test_doc.pdf", + "page": 42 + } + + @pytest.mark.asyncio + async def test_run_upload_chunk(self, qdrant_engine, mock_client): + """Test run_upload_chunk method""" + # Setup mock response + mock_response = MagicMock() + mock_response.status = models.UpdateStatus.ACKNOWLEDGED + mock_client.upsert.return_value = mock_response + + # Create test point + test_point = models.PointStruct( + id="test-point-1", + vector=[0.1, 0.2, 0.3], + payload={"content": "test"} + ) + + result = await qdrant_engine.run_upload_chunk("test_index", test_point) + + assert result is True + mock_client.upsert.assert_called_once_with( + collection_name="test_index", + points=[test_point] + ) + + @pytest.mark.asyncio + async def test_run_upload_chunk_failure(self, qdrant_engine, mock_client): + """Test run_upload_chunk method when upload fails""" + # Setup mock response with failure status + mock_response = MagicMock() + mock_response.status = models.UpdateStatus.COMPLETED # Not ACKNOWLEDGED + mock_client.upsert.return_value = mock_response + + test_point = models.PointStruct( + id="test-point-1", + vector=[0.1, 0.2, 0.3], + payload={"content": "test"} + ) + + result = await qdrant_engine.run_upload_chunk("test_index", test_point) + + assert result is False + + @pytest.mark.asyncio + async def test_upload_chunk_integration(self, qdrant_engine, mock_client): + """Test the complete upload_chunk workflow""" + from vector_search_mcp.models import Chunk, ChunkData + + # Setup mock response + mock_response = MagicMock() + mock_response.status = models.UpdateStatus.ACKNOWLEDGED + mock_client.upsert.return_value = mock_response + + chunk = Chunk( + id="integration-test-chunk", + vector=[0.5, 0.4, 0.3, 0.2, 0.1], + payload=ChunkData( + page_content="Integration test content", + filename="integration_test.pdf", + page=1 + ) + ) + + result = await qdrant_engine.upload_chunk("integration_collection", chunk) + + assert result is True + # Verify the complete workflow: transform_chunk -> run_upload_chunk + mock_client.upsert.assert_called_once() + args, kwargs = mock_client.upsert.call_args + + assert kwargs["collection_name"] == "integration_collection" + assert len(kwargs["points"]) == 1 + + uploaded_point = kwargs["points"][0] + assert uploaded_point.id == "integration-test-chunk" + assert uploaded_point.vector == [0.5, 0.4, 0.3, 0.2, 0.1] + assert uploaded_point.payload == { + "page_content": "Integration test content", + "filename": "integration_test.pdf", + "page": 1 + } def test_transform_response_with_scored_points(self, qdrant_engine): """Test transform_response with valid ScoredPoint objects""" diff --git a/tests/test_mcp/test_mcp.py b/tests/test_mcp/test_mcp.py index 2810498..0abc257 100644 --- a/tests/test_mcp/test_mcp.py +++ b/tests/test_mcp/test_mcp.py @@ -1,31 +1,129 @@ -import json +"""Tests for the MCP server implementation.""" +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest from fastembed import TextEmbedding from fastmcp import Client from mcp.types import TextContent -async def test_call_tool(embedding_model: TextEmbedding, run_mcp: str): - input = "Quien es el mas guapo?" - collection = "dummy_collection" +class TestMCPServer: + """Test the MCP server implementation.""" - embedding: list[float] = list(embedding_model.embed(input))[0].tolist() + def test_server_import(self): + """Test that MCP server can be imported successfully.""" + from vector_search_mcp.mcp_server import server - client = Client(run_mcp) + assert hasattr(server, 'mcp') + assert hasattr(server, 'engine') - async with client: - name = "semantic_search" - body = {"embedding": embedding, "collection": collection} - result = await client.call_tool(name, body) + def test_server_initialization(self): + """Test that the MCP server initializes correctly.""" + from vector_search_mcp.mcp_server import server + from vector_search_mcp.engine import Backend - content_block = result.content[0] + # Verify server module attributes exist + assert hasattr(server, 'mcp') + assert hasattr(server, 'engine') - assert isinstance(content_block, TextContent) + # The engine should be created during module import + # We can't easily test the exact call without complex mocking + # but we can verify the engine exists and is properly typed + assert server.engine is not None - deserialized_result = json.loads(content_block.text) + def test_run_function_exists(self): + """Test that the run function exists in the package init.""" + from vector_search_mcp.mcp_server import run - top_result = deserialized_result[0] + assert callable(run) - assert top_result["chunk_id"] == "0" - assert top_result["score"] > 0.7 - assert top_result["payload"] == {"text": "Rick es el mas guapo"} + def test_run_function_signature(self): + """Test that run function has correct signature and docstring.""" + from vector_search_mcp.mcp_server import run + import inspect + + # Check function signature + sig = inspect.signature(run) + params = list(sig.parameters.values()) + + assert len(params) == 1 + assert params[0].name == "transport" + assert params[0].default == "sse" + + # Check docstring + assert run.__doc__ is not None + assert "transport" in run.__doc__.lower() + + def test_run_function_type_annotations(self): + """Test that run function has proper type annotations.""" + from vector_search_mcp.mcp_server import run + + # Verify function exists and is callable + assert callable(run) + + # The function should accept Transport type + import inspect + sig = inspect.signature(run) + assert "transport" in sig.parameters + + +class TestMCPIntegration: + """Integration tests for the MCP server.""" + + async def test_call_tool(self, embedding_model: TextEmbedding, run_mcp: str): + """Test calling the semantic search tool via MCP.""" + input = "Quien es el mas guapo?" + collection = "dummy_collection" + + embedding: list[float] = list(embedding_model.embed(input))[0].tolist() + + client = Client(run_mcp) + + async with client: + name = "semantic_search" + body = {"embedding": embedding, "collection": collection} + result = await client.call_tool(name, body) + + content_block = result.content[0] + + assert isinstance(content_block, TextContent) + + deserialized_result = json.loads(content_block.text) + + top_result = deserialized_result[0] + + assert top_result["chunk_id"] == "0" + assert top_result["score"] > 0.7 + assert top_result["payload"] == {"text": "Rick es el mas guapo"} + + def test_semantic_search_tool_registration(self): + """Test that semantic_search tool registration is accessible.""" + from vector_search_mcp.mcp_server.server import mcp + + # Just verify the mcp object exists and is properly configured + # The actual tool registration happens during import + assert mcp is not None + assert hasattr(mcp, 'tool') # Has the decorator method + + def test_server_module_attributes(self): + """Test that server module has expected attributes.""" + from vector_search_mcp.mcp_server import server + + assert hasattr(server, 'mcp') + assert hasattr(server, 'engine') + + # Verify mcp is a FastMCP instance + from fastmcp import FastMCP + assert isinstance(server.mcp, FastMCP) + + def test_package_init_exports(self): + """Test that package __init__ exports the run function.""" + from vector_search_mcp.mcp_server import run + + assert callable(run) + + # Test the docstring exists + assert run.__doc__ is not None + assert "transport" in run.__doc__.lower() diff --git a/uv.lock b/uv.lock index 6ef7c46..43ad112 100644 --- a/uv.lock +++ b/uv.lock @@ -1707,6 +1707,7 @@ mcp = [ [package.dev-dependencies] dev = [ { name = "fastembed" }, + { name = "fastmcp" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -1725,6 +1726,7 @@ provides-extras = ["mcp"] [package.metadata.requires-dev] dev = [ { name = "fastembed", specifier = ">=0.7.3" }, + { name = "fastmcp", extras = ["mcp"], specifier = ">=2.12.3" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-cov", specifier = ">=7.0.0" },