Files
basicmachines-co-basic-memory/test-int/mcp/test_search_integration.py
K Jagadeeswara Reddy 44ecec2917 test(mcp): assert tags param and tag: shorthand comma equivalence
Integration regression test for #910: the tags= parameter and the
tag:alpha,beta query shorthand must return the same results for the
same comma string.

Cherry-picked from #918. The validator swap itself (coerce_list ->
parse_tags) landed separately via #932; this carries the remaining
test-only piece with original authorship.

Signed-off-by: K Jagadeeswara Reddy <social@jagadeeswar.com>
2026-06-10 12:04:07 -05:00

552 lines
18 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}"
@pytest.mark.asyncio
async def test_tags_param_vs_tag_query_comma_consistency(mcp_server, app, test_project):
"""The `tags=` parameter must split comma-separated strings like the `tag:` shorthand.
Regression test for #910: `search_notes(tags="alpha,beta")` previously coerced the
bare string into the single literal tag `["alpha,beta"]` (matching nothing), while
the `tag:alpha,beta` query shorthand splits on commas. Both paths must agree.
"""
async with Client(mcp_server) as client:
# Note tagged alpha + beta
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Tag Shorthand Note",
"directory": "tag-shorthand",
"content": "# Tag Shorthand Note\n\nTagShorthandToken body",
"tags": ["alpha", "beta"],
},
)
# Path A: tag: query shorthand with comma list -> splits, matches
via_query = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "tag:alpha,beta",
"search_type": "text",
},
)
query_hit = "Tag Shorthand Note" in via_query.content[0].text
# Path B: tags= parameter with the SAME comma string
via_param = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "TagShorthandToken",
"search_type": "text",
"tags": "alpha,beta",
},
)
param_hit = "Tag Shorthand Note" in via_param.content[0].text
assert query_hit, "tag: query shorthand should match (sanity)"
assert param_hit == query_hit, (
"tags='alpha,beta' param must behave like the tag: shorthand "
f"(both split commas). query_hit={query_hit} param_hit={param_hit}"
)