Add 97% test coverage

This commit is contained in:
2025-09-26 23:41:54 +00:00
parent 2b76760fe6
commit e162b0613b
7 changed files with 530 additions and 21 deletions

View File

@@ -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",

View File

@@ -0,0 +1 @@
"""Tests for the vector search client module."""

View File

@@ -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")

View File

@@ -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"""

View File

@@ -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"""

View File

@@ -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()

2
uv.lock generated
View File

@@ -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" },