"""Tests for search MCP tools.""" import pytest from contextlib import asynccontextmanager from datetime import datetime, timedelta from basic_memory.mcp.tools import write_note from basic_memory.mcp.tools.search import search_notes, _format_search_error_response from basic_memory.schemas.search import SearchResponse @pytest.mark.asyncio async def test_search_text(client, test_project): """Test basic search functionality.""" # Create a test note result = await write_note.fn( project=test_project.name, title="Test Search Note", directory="test", content="# Test\nThis is a searchable test note", tags=["test", "search"], ) assert result # Search for it response = await search_notes.fn(project=test_project.name, query="searchable") # Verify results - handle both success and error cases if isinstance(response, SearchResponse): # Success case - verify SearchResponse assert len(response.results) > 0 assert any( r.permalink == f"{test_project.name}/test/test-search-note" for r in response.results ) else: # If search failed and returned error message, test should fail with informative message pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio async def test_search_title(client, test_project): """Test basic search functionality.""" # Create a test note result = await write_note.fn( project=test_project.name, title="Test Search Note", directory="test", content="# Test\nThis is a searchable test note", tags=["test", "search"], ) assert result # Search for it response = await search_notes.fn( project=test_project.name, query="Search Note", search_type="title" ) # Verify results - handle both success and error cases if isinstance(response, str): # If search failed and returned error message, test should fail with informative message pytest.fail(f"Search failed with error: {response}") else: # Success case - verify SearchResponse assert len(response.results) > 0 assert any( r.permalink == f"{test_project.name}/test/test-search-note" for r in response.results ) @pytest.mark.asyncio async def test_search_permalink(client, test_project): """Test basic search functionality.""" # Create a test note result = await write_note.fn( project=test_project.name, title="Test Search Note", directory="test", content="# Test\nThis is a searchable test note", tags=["test", "search"], ) assert result # Search for it response = await search_notes.fn( project=test_project.name, query=f"{test_project.name}/test/test-search-note", search_type="permalink", ) # Verify results - handle both success and error cases if isinstance(response, SearchResponse): # Success case - verify SearchResponse assert len(response.results) > 0 assert any( r.permalink == f"{test_project.name}/test/test-search-note" for r in response.results ) else: # If search failed and returned error message, test should fail with informative message pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio async def test_search_permalink_match(client, test_project): """Test basic search functionality.""" # Create a test note result = await write_note.fn( project=test_project.name, title="Test Search Note", directory="test", content="# Test\nThis is a searchable test note", tags=["test", "search"], ) assert result # Search for it response = await search_notes.fn( project=test_project.name, query=f"{test_project.name}/test/test-search-*", search_type="permalink", ) # Verify results - handle both success and error cases if isinstance(response, SearchResponse): # Success case - verify SearchResponse assert len(response.results) > 0 assert any( r.permalink == f"{test_project.name}/test/test-search-note" for r in response.results ) else: # If search failed and returned error message, test should fail with informative message pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio async def test_search_memory_url_with_project_prefix(client, test_project): """Test searching with a memory:// URL that includes the project prefix.""" result = await write_note.fn( project=test_project.name, title="Memory URL Search Note", directory="test", content="# Memory URL Search\nThis note should be found via memory URL search", ) assert result response = await search_notes.fn( query=f"memory://{test_project.name}/test/memory-url-search-note" ) if isinstance(response, SearchResponse): assert len(response.results) > 0 assert any( r.permalink == f"{test_project.name}/test/memory-url-search-note" for r in response.results ) else: pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio async def test_search_pagination(client, test_project): """Test basic search functionality.""" # Create a test note result = await write_note.fn( project=test_project.name, title="Test Search Note", directory="test", content="# Test\nThis is a searchable test note", tags=["test", "search"], ) assert result # Search for it response = await search_notes.fn( project=test_project.name, query="searchable", page=1, page_size=1 ) # Verify results - handle both success and error cases if isinstance(response, SearchResponse): # Success case - verify SearchResponse assert len(response.results) == 1 assert any( r.permalink == f"{test_project.name}/test/test-search-note" for r in response.results ) else: # If search failed and returned error message, test should fail with informative message pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio async def test_search_with_type_filter(client, test_project): """Test search with entity type filter.""" # Create test content await write_note.fn( project=test_project.name, title="Entity Type Test", directory="test", content="# Test\nFiltered by type", ) # Search with type filter response = await search_notes.fn(project=test_project.name, query="type", types=["note"]) # Verify results - handle both success and error cases if isinstance(response, SearchResponse): # Success case - verify all results are entities assert all(r.type == "entity" for r in response.results) else: # If search failed and returned error message, test should fail with informative message pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio async def test_search_with_entity_type_filter(client, test_project): """Test search with entity type filter.""" # Create test content await write_note.fn( project=test_project.name, title="Entity Type Test", directory="test", content="# Test\nFiltered by type", ) # Search with entity type filter response = await search_notes.fn( project=test_project.name, query="type", entity_types=["entity"] ) # Verify results - handle both success and error cases if isinstance(response, SearchResponse): # Success case - verify all results are entities assert all(r.type == "entity" for r in response.results) else: # If search failed and returned error message, test should fail with informative message pytest.fail(f"Search failed with error: {response}") @pytest.mark.asyncio async def test_search_with_date_filter(client, test_project): """Test search with date filter.""" # Create test content await write_note.fn( project=test_project.name, title="Recent Note", directory="test", content="# Test\nRecent content", ) # Search with date filter one_hour_ago = datetime.now() - timedelta(hours=1) response = await search_notes.fn( project=test_project.name, query="recent", after_date=one_hour_ago.isoformat() ) # Verify results - handle both success and error cases if isinstance(response, SearchResponse): # Success case - verify we get results within timeframe assert len(response.results) > 0 else: # If search failed and returned error message, test should fail with informative message pytest.fail(f"Search failed with error: {response}") class TestSearchErrorFormatting: """Test search error formatting for better user experience.""" def test_format_search_error_fts5_syntax(self): """Test formatting for FTS5 syntax errors.""" result = _format_search_error_response( "test-project", "syntax error in FTS5", "test query(" ) assert "# Search Failed - Invalid Syntax" in result assert "The search query 'test query(' contains invalid syntax" in result assert "Special characters" in result assert "test query" in result # Clean query without special chars def test_format_search_error_no_results(self): """Test formatting for no results found.""" result = _format_search_error_response( "test-project", "no results found", "very specific query" ) assert "# Search Complete - No Results Found" in result assert "No content found matching 'very specific query'" in result assert "Broaden your search" in result assert "very" in result # Simplified query def test_format_search_error_server_error(self): """Test formatting for server errors.""" result = _format_search_error_response( "test-project", "internal server error", "test query" ) assert "# Search Failed - Server Error" in result assert "The search service encountered an error while processing 'test query'" in result assert "Try again" in result assert "Check project status" in result def test_format_search_error_permission_denied(self): """Test formatting for permission errors.""" result = _format_search_error_response("test-project", "permission denied", "test query") assert "# Search Failed - Access Error" in result assert "You don't have permission to search" in result assert "Check your project access" in result def test_format_search_error_project_not_found(self): """Test formatting for project not found errors.""" result = _format_search_error_response( "test-project", "current project not found", "test query" ) assert "# Search Failed - Project Not Found" in result assert "The current project is not accessible" in result assert "Check available projects" in result def test_format_search_error_semantic_disabled(self): """Test formatting for semantic-search-disabled errors.""" result = _format_search_error_response( "test-project", "Semantic search is disabled. Set BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true.", "semantic query", "vector", ) assert "# Search Failed - Semantic Search Disabled" in result assert "BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true" in result assert 'search_type="text"' in result def test_format_search_error_semantic_dependencies_missing(self): """Test formatting for missing semantic dependencies.""" result = _format_search_error_response( "test-project", "fastembed package is missing. Install semantic extras: pip install 'basic-memory[semantic]'", "semantic query", "hybrid", ) assert "# Search Failed - Semantic Dependencies Missing" in result assert "pip install 'basic-memory[semantic]'" in result def test_format_search_error_generic(self): """Test formatting for generic errors.""" result = _format_search_error_response("test-project", "unknown error", "test query") assert "# Search Failed" in result assert "Error searching for 'test query': unknown error" in result assert "## Troubleshooting steps:" in result class TestSearchToolErrorHandling: """Test search tool exception handling.""" @pytest.mark.asyncio async def test_search_notes_exception_handling(self, monkeypatch): """Test exception handling in search_notes.""" import importlib search_mod = importlib.import_module("basic_memory.mcp.tools.search") clients_mod = importlib.import_module("basic_memory.mcp.clients") class StubProject: name = "test-project" external_id = "test-external-id" @asynccontextmanager async def fake_get_project_client(*args, **kwargs): yield (object(), StubProject()) async def fake_resolve_project_and_path( client, identifier, project=None, context=None, headers=None ): return StubProject(), identifier, False # Mock SearchClient to raise an exception class MockSearchClient: def __init__(self, *args, **kwargs): pass async def search(self, *args, **kwargs): raise Exception("syntax error") monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client) monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path) # Patch at the clients module level where the import happens monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient) result = await search_mod.search_notes.fn(project="test-project", query="test query") assert isinstance(result, str) assert "# Search Failed - Invalid Syntax" in result @pytest.mark.asyncio async def test_search_notes_permission_error(self, monkeypatch): """Test search_notes with permission error.""" import importlib search_mod = importlib.import_module("basic_memory.mcp.tools.search") clients_mod = importlib.import_module("basic_memory.mcp.clients") class StubProject: name = "test-project" external_id = "test-external-id" @asynccontextmanager async def fake_get_project_client(*args, **kwargs): yield (object(), StubProject()) async def fake_resolve_project_and_path( client, identifier, project=None, context=None, headers=None ): return StubProject(), identifier, False # Mock SearchClient to raise a permission error class MockSearchClient: def __init__(self, *args, **kwargs): pass async def search(self, *args, **kwargs): raise Exception("permission denied") monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client) monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path) # Patch at the clients module level where the import happens monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient) result = await search_mod.search_notes.fn(project="test-project", query="test query") assert isinstance(result, str) assert "# Search Failed - Access Error" in result @pytest.mark.asyncio @pytest.mark.parametrize("search_type", ["vector", "hybrid"]) async def test_search_notes_sets_retrieval_mode_for_semantic_types(monkeypatch, search_type): """Vector/hybrid search types should populate retrieval_mode in API payload.""" import importlib search_mod = importlib.import_module("basic_memory.mcp.tools.search") clients_mod = importlib.import_module("basic_memory.mcp.clients") class StubProject: project_url = "http://test" name = "test-project" id = 1 external_id = "test-external-id" @asynccontextmanager async def fake_get_project_client(*args, **kwargs): yield (object(), StubProject()) async def fake_resolve_project_and_path( client, identifier, project=None, context=None, headers=None ): return StubProject(), identifier, False captured_payload: dict = {} class MockSearchClient: def __init__(self, *args, **kwargs): pass async def search(self, payload, page, page_size): captured_payload.update(payload) return SearchResponse(results=[], current_page=page, page_size=page_size) monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client) monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path) monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient) result = await search_mod.search_notes.fn( project="test-project", query="semantic lookup", search_type=search_type, ) assert isinstance(result, SearchResponse) assert captured_payload["text"] == "semantic lookup" assert captured_payload["retrieval_mode"] == search_type