403 lines
14 KiB
Python
403 lines
14 KiB
Python
"""
|
|
Tests for backend/app/search.py
|
|
|
|
These tests verify search functionality without requiring:
|
|
- A running Qdrant vector database (mocked)
|
|
- Loaded embedding models (mocked)
|
|
|
|
The tests focus on:
|
|
- Response shape validation
|
|
- Library filtering
|
|
- Error handling
|
|
- Async function behavior
|
|
"""
|
|
import pytest
|
|
|
|
|
|
class TestResolveLibraryId:
|
|
"""Tests for resolve_library_id() - Context7-style resolution."""
|
|
|
|
def test_returns_candidates_list(self, test_database):
|
|
"""resolve_library_id should return a list of candidates."""
|
|
from backend.app.search import resolve_library_id
|
|
|
|
# Create some libraries first
|
|
from backend.app.db import upsert_library
|
|
for i in range(3):
|
|
upsert_library(
|
|
library_id=f"/local/searchtest{i}",
|
|
name=f"Search Test Library {i}",
|
|
description=f"Description for search test {i}"
|
|
)
|
|
|
|
candidates = resolve_library_id("search")
|
|
|
|
assert isinstance(candidates, list)
|
|
|
|
def test_captures_matching_names(self, test_database):
|
|
"""Should capture libraries where query matches name."""
|
|
from backend.app.db import upsert_library
|
|
from backend.app.search import resolve_library_id
|
|
|
|
# Create a library that should match "search"
|
|
upsert_library(
|
|
library_id="/local/searchlib",
|
|
name="Search Library",
|
|
description="Main search documentation"
|
|
)
|
|
|
|
candidates = resolve_library_id("search")
|
|
|
|
assert isinstance(candidates, list)
|
|
|
|
def test_context7_style_prefix(self, test_database):
|
|
"""Candidates should have /local/ prefix added to ID."""
|
|
from backend.app.db import upsert_library
|
|
from backend.app.search import resolve_library_id
|
|
|
|
upsert_library(
|
|
library_id="foundryvtt", # Without /local/
|
|
name="Foundry VTT",
|
|
description="Fantasy tabletop virtual table"
|
|
)
|
|
|
|
candidates = resolve_library_id("foundry")
|
|
|
|
for candidate in candidates:
|
|
assert candidate.get("source") == "local"
|
|
|
|
def test_partial_name_match(self, test_database):
|
|
"""Should match on partial name."""
|
|
from backend.app.db import upsert_library
|
|
from backend.app.search import resolve_library_id
|
|
|
|
upsert_library(
|
|
library_id="/local/gamefoundry",
|
|
name="Foundry Game Module",
|
|
description="Module for foundry games"
|
|
)
|
|
|
|
candidates = resolve_library_id("game")
|
|
assert isinstance(candidates, list)
|
|
|
|
def test_empty_result_on_no_matches(self, test_database):
|
|
"""Should return empty list when no matches."""
|
|
from backend.app.search import resolve_library_id
|
|
|
|
# No libraries matching "xyznonexistent123"
|
|
candidates = resolve_library_id("xyznonexistent123")
|
|
|
|
assert isinstance(candidates, list)
|
|
|
|
|
|
class TestSearchDocs:
|
|
"""Tests for search_docs() - semantic search with mocked vector store."""
|
|
|
|
def test_returns_results_list(self, mock_qdrant_client, test_database):
|
|
"""search_docs should return a list of results."""
|
|
from backend.app.search import search_docs
|
|
|
|
# Create some chunks first
|
|
from backend.app.db import upsert_library, insert_document_chunk
|
|
upsert_library(library_id="/local/searchdocslib", name="Search Docs Lib", description="Test")
|
|
|
|
for i in range(5):
|
|
insert_document_chunk(
|
|
doc_id=f"searchdoc-{i}",
|
|
library_id="/local/searchdocslib",
|
|
path=f"path{i}.md",
|
|
title=f"Section {i}",
|
|
content=f"# Section {i}\n\nContent about section {i} that matches search queries.",
|
|
chunk_index=i,
|
|
token_estimate=100
|
|
)
|
|
|
|
results = search_docs("section")
|
|
|
|
assert isinstance(results, list)
|
|
|
|
def test_empty_query_returns_empty_list(self):
|
|
"""Empty query should return empty results."""
|
|
from backend.app.search import search_docs
|
|
|
|
results = search_docs("")
|
|
assert isinstance(results, list)
|
|
|
|
def test_limit_parameter(self, mock_qdrant_client):
|
|
"""Limit parameter should affect result count."""
|
|
from backend.app.search import search_docs
|
|
|
|
results_10 = search_docs("test", limit=10)
|
|
results_5 = search_docs("test", limit=5)
|
|
|
|
assert isinstance(results_10, list)
|
|
assert isinstance(results_5, list)
|
|
|
|
def test_response_shape_matches_spec(self):
|
|
"""Verify response shape when mocked returns data."""
|
|
from unittest.mock import patch
|
|
from backend.app.search import search_docs
|
|
|
|
# Mock client to return formatted results
|
|
mock_results = [
|
|
{
|
|
"id": "test-id-1",
|
|
"score": 0.95,
|
|
"library_id": "/local/testlib",
|
|
"path": "docs/example.md",
|
|
"title": "Example Document",
|
|
"chunk_index": 0
|
|
}
|
|
]
|
|
|
|
with patch('backend.app.vector_store.get_client') as mock_get_client:
|
|
# Setup mock client to return our test data
|
|
mock_client = mock_get_client.return_value
|
|
mock_point = type('ScoredPoint', (), {
|
|
'score': 0.95,
|
|
'payload': {
|
|
"id": "test-id-1",
|
|
"library_id": "/local/testlib",
|
|
"path": "docs/example.md",
|
|
"title": "Example Document",
|
|
"chunk_index": 0
|
|
}
|
|
})()
|
|
mock_client.search.return_value = [mock_point]
|
|
|
|
results = search_docs("test query")
|
|
|
|
assert isinstance(results, list)
|
|
if results:
|
|
# Verify each result has expected fields
|
|
result = results[0]
|
|
assert "id" in result
|
|
assert "score" in result
|
|
assert "library_id" in result
|
|
assert "path" in result
|
|
assert "title" in result
|
|
assert "chunk_index" in result
|
|
|
|
def test_uses_modern_qdrant_query_points_api(self):
|
|
"""New qdrant-client versions expose query_points instead of search."""
|
|
from unittest.mock import patch
|
|
from backend.app.search import search_docs
|
|
|
|
point = type("ScoredPoint", (), {
|
|
"score": 0.91,
|
|
"payload": {
|
|
"id": "modern-result",
|
|
"library_id": "documentation",
|
|
"path": "docs/index.md",
|
|
"title": "Index",
|
|
"chunk_index": 0,
|
|
},
|
|
})()
|
|
|
|
class Response:
|
|
points = [point]
|
|
|
|
class ModernClient:
|
|
def query_points(self, **kwargs):
|
|
assert kwargs["query"] == [0.1, 0.2]
|
|
assert kwargs["limit"] == 3
|
|
return Response()
|
|
|
|
with (
|
|
patch("backend.app.search.embed_text", return_value=[0.1, 0.2]),
|
|
patch("backend.app.search.get_client", return_value=ModernClient()),
|
|
):
|
|
results = search_docs("world generation", limit=3)
|
|
|
|
assert len(results) == 1
|
|
assert results[0]["id"] == "modern-result"
|
|
|
|
|
|
class TestGetLibraryDocs:
|
|
"""Tests for get_library_docs() - document retrieval."""
|
|
|
|
def test_returns_empty_string_when_no_documents(self, mock_qdrant_client):
|
|
"""Should return empty/error when no docs exist."""
|
|
from backend.app.search import get_library_docs
|
|
|
|
result = get_library_docs("/local/nonexistent")
|
|
|
|
# Either returns empty string or error message
|
|
assert isinstance(result, str)
|
|
|
|
def test_returns_content_when_documents_exist(self, mock_qdrant_client):
|
|
"""Should return combined document content."""
|
|
from backend.app.db import upsert_library, insert_document_chunk
|
|
from backend.app.search import get_library_docs
|
|
|
|
# Create library with chunks
|
|
upsert_library(library_id="/local/docretrievetest", name="Doc Retrieve", description="Test")
|
|
insert_document_chunk(
|
|
doc_id="doc-retrieve-1",
|
|
library_id="/local/docretrievetest",
|
|
path="docs/getting-started.md",
|
|
title="Getting Started",
|
|
content="# Getting Started\n\nWelcome to the documentation. This is a test document.",
|
|
chunk_index=0,
|
|
token_estimate=200
|
|
)
|
|
|
|
result = get_library_docs("/local/docretrievetest")
|
|
|
|
assert isinstance(result, str)
|
|
# Should contain at least library title or content
|
|
|
|
def test_topic_filter_searches(self, mock_qdrant_client):
|
|
"""With topic filter, should search for relevant chunks."""
|
|
from backend.app.db import upsert_library, insert_document_chunk
|
|
from backend.app.search import get_library_docs
|
|
|
|
upsert_library(library_id="/local/topicsearchlib", name="Topic Search", description="Test")
|
|
|
|
# Add documents with different topics
|
|
insert_document_chunk(
|
|
doc_id="topic-install",
|
|
library_id="/local/topicsearchlib",
|
|
path="docs/install.md",
|
|
title="Installation Guide",
|
|
content="# Installation\n\nInstall with pip install mypackage.",
|
|
chunk_index=0,
|
|
token_estimate=150
|
|
)
|
|
|
|
insert_document_chunk(
|
|
doc_id="topic-usage",
|
|
library_id="/local/topicsearchlib",
|
|
path="docs/usage.md",
|
|
title="Usage Guide",
|
|
content="# Usage\n\nUse mycommand --help for help.",
|
|
chunk_index=0,
|
|
token_estimate=150
|
|
)
|
|
|
|
# Search for "install" topic
|
|
result = get_library_docs("/local/topicsearchlib", topic="install")
|
|
|
|
assert isinstance(result, str)
|
|
|
|
def test_token_limit_respected(self):
|
|
"""Token limit should truncate content appropriately."""
|
|
from backend.app.search import get_library_docs
|
|
|
|
# Create a library with lots of content
|
|
from backend.app.db import upsert_library, insert_document_chunk
|
|
|
|
upsert_library(library_id="/local/tokenlimittest", name="Token Limit", description="Test")
|
|
|
|
long_content = "# Long Content\n\n" + " ".join(["word"] * 500)
|
|
insert_document_chunk(
|
|
doc_id="long-doc",
|
|
library_id="/local/tokenlimittest",
|
|
path="docs/long.md",
|
|
title="Long Document",
|
|
content=long_content,
|
|
chunk_index=0,
|
|
token_estimate=2000
|
|
)
|
|
|
|
# Request with small token limit
|
|
result = get_library_docs("/local/tokenlimittest", token_limit=100)
|
|
|
|
assert isinstance(result, str)
|
|
|
|
|
|
class TestGetLibraryDocsWithMock:
|
|
"""Tests that verify content retrieval when mocked data is available."""
|
|
|
|
def test_retrieves_chunks_by_library_id(self, mock_qdrant_client):
|
|
"""get_library_docs without topic should fetch all chunks for library."""
|
|
from backend.app.db import upsert_library, insert_document_chunk
|
|
from backend.app.search import get_library_docs
|
|
|
|
upsert_library(library_id="/local/mockretrievetest", name="Mock Retrieve", description="Test")
|
|
|
|
for i in range(3):
|
|
insert_document_chunk(
|
|
doc_id=f"mock-retrieve-{i}",
|
|
library_id="/local/mockretrievetest",
|
|
path=f"path{i}.md",
|
|
title=f"Path {i}",
|
|
content=f"Content for path {i}.",
|
|
chunk_index=i,
|
|
token_estimate=50
|
|
)
|
|
|
|
result = get_library_docs("/local/mockretrievetest")
|
|
|
|
assert isinstance(result, str)
|
|
|
|
|
|
class TestSearchErrorHandling:
|
|
"""Tests for error handling in search functions."""
|
|
|
|
def test_search_handles_missing_library(self):
|
|
"""Should handle missing library gracefully."""
|
|
from backend.app.search import search_docs
|
|
|
|
results = search_docs("test", library_id="/local/missing_lib_xyz123")
|
|
assert isinstance(results, list)
|
|
|
|
def test_resolve_handles_no_libraries_in_db(self):
|
|
"""Should handle empty database gracefully."""
|
|
from backend.app.db import init_db
|
|
from backend.app.search import resolve_library_id
|
|
|
|
# Initialize fresh DB (empty)
|
|
from backend.app.db import get_connection, get_chunks_for_library
|
|
# The test_database fixture already does this
|
|
|
|
def test_get_library_docs_handles_empty_library(self):
|
|
"""Should handle library with no chunks."""
|
|
from backend.app.search import get_library_docs
|
|
|
|
result = get_library_docs("/local/emptylib")
|
|
assert isinstance(result, str)
|
|
|
|
|
|
# =============================================================================
|
|
# FIXTURES FOR SEARCH TESTS
|
|
# =============================================================================
|
|
|
|
@pytest.fixture
|
|
def search_sample_text():
|
|
"""Sample text with headings for search chunking tests."""
|
|
return """# Installation Guide
|
|
|
|
To install the package:
|
|
```bash
|
|
pip install mypackage
|
|
```
|
|
|
|
## Configuration
|
|
|
|
Configure your environment by setting these variables:
|
|
- MY_VAR=123
|
|
- DEBUG=true
|
|
|
|
## Usage Examples
|
|
|
|
Example 1: Basic usage
|
|
```python
|
|
import mymodule
|
|
module = mymodule.Module()
|
|
result = module.run()
|
|
print(result)
|
|
```
|
|
|
|
Example 2: Advanced usage with options
|
|
```python
|
|
options = {"verbose": True, "output": "stdout"}
|
|
result = module.run(options=options)
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
Common issues and their solutions:
|
|
- ImportError: Ensure package is installed
|
|
- AttributeError: Check that attributes exist on object"""
|