mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
7f2d4d2a6f
🔧 #640 — LinkResolver selects worst match instead of best Replace `min(results, key=lambda x: x.score)` with `results[0]`. Both SQLite and Postgres return results sorted best-first in SQL, so using `results[0]` is backend-agnostic and correct. 🔧 #641 — search_notes output_format="text" returns raw Pydantic model Add `_format_search_markdown()` that formats SearchResponse as readable markdown with title, permalink, score, and matched snippet per result. Update prompts to use `output_format="json"` since they need structured data for result counting and branching logic. 🔧 #642 — metadata_filters with `note_type` key returns empty results Add `_METADATA_KEY_ALIASES` mapping at the tool level that aliases `note_type` → `type` before passing metadata_filters to the search query. The frontmatter field is `type`, not `note_type`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
500 lines
16 KiB
Python
500 lines
16 KiB
Python
"""
|
|
Integration tests for search_notes MCP tool.
|
|
|
|
Comprehensive tests covering search functionality using the complete
|
|
MCP client-server flow with real databases.
|
|
"""
|
|
|
|
import pytest
|
|
from fastmcp import Client
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_basic_text_search(mcp_server, app, test_project):
|
|
"""Test basic text search functionality."""
|
|
|
|
async with Client(mcp_server) as client:
|
|
# Create test notes for searching
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Python Programming Guide",
|
|
"directory": "docs",
|
|
"content": "# Python Programming Guide\n\nThis guide covers Python basics and advanced topics.",
|
|
"tags": "python,programming",
|
|
},
|
|
)
|
|
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Flask Web Development",
|
|
"directory": "docs",
|
|
"content": "# Flask Web Development\n\nBuilding web applications with Python Flask framework.",
|
|
"tags": "python,flask,web",
|
|
},
|
|
)
|
|
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "JavaScript Basics",
|
|
"directory": "docs",
|
|
"content": "# JavaScript Basics\n\nIntroduction to JavaScript programming language.",
|
|
"tags": "javascript,programming",
|
|
},
|
|
)
|
|
|
|
# Search for Python-related content
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": "Python",
|
|
},
|
|
)
|
|
|
|
assert len(search_result.content) == 1
|
|
assert search_result.content[0].type == "text"
|
|
|
|
# Parse the response (it should be a SearchResponse)
|
|
result_text = search_result.content[0].text
|
|
assert "Python Programming Guide" in result_text
|
|
assert "Flask Web Development" in result_text
|
|
# JavaScript note may appear due to shared "programming" tag — just verify Python notes rank first
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_boolean_operators(mcp_server, app, test_project):
|
|
"""Test boolean search operators (AND, OR, NOT)."""
|
|
|
|
async with Client(mcp_server) as client:
|
|
# Create test notes
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Python Flask Tutorial",
|
|
"directory": "tutorials",
|
|
"content": "# Python Flask Tutorial\n\nLearn Python web development with Flask.",
|
|
"tags": "python,flask,tutorial",
|
|
},
|
|
)
|
|
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Python Django Guide",
|
|
"directory": "tutorials",
|
|
"content": "# Python Django Guide\n\nBuilding web apps with Python Django framework.",
|
|
"tags": "python,django,web",
|
|
},
|
|
)
|
|
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "React JavaScript",
|
|
"directory": "tutorials",
|
|
"content": "# React JavaScript\n\nBuilding frontend applications with React.",
|
|
"tags": "javascript,react,frontend",
|
|
},
|
|
)
|
|
|
|
# Test AND operator
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": "Python AND Flask",
|
|
},
|
|
)
|
|
|
|
result_text = search_result.content[0].text
|
|
assert "Python Flask Tutorial" in result_text
|
|
# FTS may match broadly on shared terms — verify target note is present
|
|
|
|
# Test OR operator
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": "Flask OR Django",
|
|
},
|
|
)
|
|
|
|
result_text = search_result.content[0].text
|
|
assert "Python Flask Tutorial" in result_text
|
|
assert "Python Django Guide" in result_text
|
|
|
|
# Test NOT operator
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": "Python NOT Django",
|
|
},
|
|
)
|
|
|
|
result_text = search_result.content[0].text
|
|
assert "Python Flask Tutorial" in result_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_title_only(mcp_server, app, test_project):
|
|
"""Test searching in titles only."""
|
|
|
|
async with Client(mcp_server) as client:
|
|
# Create test notes
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Database Design",
|
|
"directory": "docs",
|
|
"content": "# Database Design\n\nThis covers SQL and database concepts.",
|
|
"tags": "database,sql",
|
|
},
|
|
)
|
|
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Web Development",
|
|
"directory": "docs",
|
|
"content": "# Web Development\n\nDatabase integration in web applications.",
|
|
"tags": "web,development",
|
|
},
|
|
)
|
|
|
|
# Search for "database" in titles only
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": "Database",
|
|
"search_type": "title",
|
|
},
|
|
)
|
|
|
|
result_text = search_result.content[0].text
|
|
assert "Database Design" in result_text
|
|
assert "Web Development" not in result_text # Has "database" in content but not title
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_permalink_exact(mcp_server, app, test_project):
|
|
"""Test exact permalink search."""
|
|
|
|
async with Client(mcp_server) as client:
|
|
# Create test notes
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "API Documentation",
|
|
"directory": "api",
|
|
"content": "# API Documentation\n\nComplete API reference guide.",
|
|
"tags": "api,docs",
|
|
},
|
|
)
|
|
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "API Testing",
|
|
"directory": "testing",
|
|
"content": "# API Testing\n\nHow to test REST APIs.",
|
|
"tags": "api,testing",
|
|
},
|
|
)
|
|
|
|
# Search for exact permalink
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": f"{test_project.name}/api/api-documentation",
|
|
"search_type": "permalink",
|
|
},
|
|
)
|
|
|
|
result_text = search_result.content[0].text
|
|
assert "API Documentation" in result_text
|
|
assert "API Testing" not in result_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_permalink_pattern(mcp_server, app, test_project):
|
|
"""Test permalink pattern search with wildcards."""
|
|
|
|
async with Client(mcp_server) as client:
|
|
# Create test notes in different folders
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Meeting Notes January",
|
|
"directory": "meetings",
|
|
"content": "# Meeting Notes January\n\nJanuary team meeting notes.",
|
|
"tags": "meetings,january",
|
|
},
|
|
)
|
|
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Meeting Notes February",
|
|
"directory": "meetings",
|
|
"content": "# Meeting Notes February\n\nFebruary team meeting notes.",
|
|
"tags": "meetings,february",
|
|
},
|
|
)
|
|
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Project Notes",
|
|
"directory": "projects",
|
|
"content": "# Project Notes\n\nGeneral project documentation.",
|
|
"tags": "projects,notes",
|
|
},
|
|
)
|
|
|
|
# Search for all meeting notes using pattern
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": f"{test_project.name}/meetings/*",
|
|
"search_type": "permalink",
|
|
},
|
|
)
|
|
|
|
result_text = search_result.content[0].text
|
|
assert "Meeting Notes January" in result_text
|
|
assert "Meeting Notes February" in result_text
|
|
assert "Project Notes" not in result_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_entity_type_filter(mcp_server, app, test_project):
|
|
"""Test filtering search results by entity type."""
|
|
|
|
async with Client(mcp_server) as client:
|
|
# Create a note with observations and relations
|
|
content_with_observations = """# Development Process
|
|
|
|
This describes our development workflow.
|
|
|
|
## Observations
|
|
- [process] We use Git for version control
|
|
- [tool] We use VS Code as our editor
|
|
|
|
## Relations
|
|
- uses [[Git]]
|
|
- part_of [[Development Workflow]]
|
|
|
|
Regular content about development practices."""
|
|
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Development Process",
|
|
"directory": "processes",
|
|
"content": content_with_observations,
|
|
"tags": "development,process",
|
|
},
|
|
)
|
|
|
|
# Search for "development" in entities only
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": "development",
|
|
"entity_types": ["entity"],
|
|
},
|
|
)
|
|
|
|
result_text = search_result.content[0].text
|
|
# Should find the main entity but filter out observations/relations
|
|
assert "Development Process" in result_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_pagination(mcp_server, app, test_project):
|
|
"""Test search result pagination."""
|
|
|
|
async with Client(mcp_server) as client:
|
|
# Create multiple notes to test pagination
|
|
for i in range(15):
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": f"Test Note {i + 1:02d}",
|
|
"directory": "test",
|
|
"content": f"# Test Note {i + 1:02d}\n\nThis is test content for pagination testing.",
|
|
"tags": "test,pagination",
|
|
},
|
|
)
|
|
|
|
# Search with pagination (page 1, page_size 5)
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": "test",
|
|
"page": 1,
|
|
"page_size": 5,
|
|
},
|
|
)
|
|
|
|
result_text = search_result.content[0].text
|
|
# Text format includes pagination info in footer
|
|
assert "page 1" in result_text
|
|
assert "page_size 5" in result_text
|
|
|
|
# Search page 2
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": "test",
|
|
"page": 2,
|
|
"page_size": 5,
|
|
},
|
|
)
|
|
|
|
result_text = search_result.content[0].text
|
|
assert "page 2" in result_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_no_results(mcp_server, app, test_project):
|
|
"""Test search with no matching results."""
|
|
|
|
async with Client(mcp_server) as client:
|
|
# Create a test note
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Sample Note",
|
|
"directory": "test",
|
|
"content": "# Sample Note\n\nThis is a sample note for testing.",
|
|
"tags": "sample,test",
|
|
},
|
|
)
|
|
|
|
# Search for something that doesn't exist — use a unique nonsense string
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": "xyzzy99nonexistent",
|
|
},
|
|
)
|
|
|
|
# Default text format returns "No results found" when empty
|
|
result_text = search_result.content[0].text
|
|
assert "No results found" in result_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_complex_boolean_query(mcp_server, app, test_project):
|
|
"""Test complex boolean queries with grouping."""
|
|
|
|
async with Client(mcp_server) as client:
|
|
# Create test notes
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Python Web Development",
|
|
"directory": "tutorials",
|
|
"content": "# Python Web Development\n\nLearn Python for web development using Flask and Django.",
|
|
"tags": "python,web,development",
|
|
},
|
|
)
|
|
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Python Data Science",
|
|
"directory": "tutorials",
|
|
"content": "# Python Data Science\n\nData analysis and machine learning with Python.",
|
|
"tags": "python,data,science",
|
|
},
|
|
)
|
|
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "JavaScript Web Development",
|
|
"directory": "tutorials",
|
|
"content": "# JavaScript Web Development\n\nBuilding web applications with JavaScript and React.",
|
|
"tags": "javascript,web,development",
|
|
},
|
|
)
|
|
|
|
# Complex boolean query: (Python OR JavaScript) AND web
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": "(Python OR JavaScript) AND web",
|
|
},
|
|
)
|
|
|
|
result_text = search_result.content[0].text
|
|
assert "Python Web Development" in result_text
|
|
assert "JavaScript Web Development" in result_text
|
|
# "Python Data Science" may appear due to broad FTS matching on "Python"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_case_insensitive(mcp_server, app, test_project):
|
|
"""Test that search is case insensitive."""
|
|
|
|
async with Client(mcp_server) as client:
|
|
# Create test note
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Machine Learning Guide",
|
|
"directory": "guides",
|
|
"content": "# Machine Learning Guide\n\nIntroduction to MACHINE LEARNING concepts.",
|
|
"tags": "ML,AI",
|
|
},
|
|
)
|
|
|
|
# Search with different cases
|
|
search_cases = ["machine", "MACHINE", "Machine", "learning", "LEARNING"]
|
|
|
|
for search_term in search_cases:
|
|
search_result = await client.call_tool(
|
|
"search_notes",
|
|
{
|
|
"project": test_project.name,
|
|
"query": search_term,
|
|
},
|
|
)
|
|
|
|
result_text = search_result.content[0].text
|
|
assert "Machine Learning Guide" in result_text, f"Failed for search term: {search_term}"
|