Files
2026-06-10 14:04:59 -05:00

829 lines
28 KiB
Python

"""
Integration tests for edit_note MCP tool.
Tests the complete edit note workflow: MCP client -> MCP server -> FastAPI -> database
"""
from pathlib import Path
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_edit_note_append_operation(mcp_server, app, test_project):
"""Test appending content to an existing note."""
async with Client(mcp_server) as client:
# First create a note
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Append Test Note",
"directory": "test",
"content": "# Append Test Note\n\nOriginal content here.",
"tags": "test,append",
},
)
# Test appending content
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Append Test Note",
"operation": "append",
"content": "\n\n## New Section\n\nThis content was appended.",
},
)
# Should return successful edit summary
assert len(edit_result.content) == 1
edit_text = edit_result.content[0].text
assert "Edited note (append)" in edit_text
assert "Added 5 lines to end of note" in edit_text
assert "test/append-test-note" in edit_text
# Verify the content was actually appended
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "Append Test Note",
},
)
content = read_result.content[0].text
assert "Original content here." in content
assert "## New Section" in content
assert "This content was appended." in content
@pytest.mark.asyncio
async def test_edit_note_prepend_operation(mcp_server, app, test_project):
"""Test prepending content to an existing note."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Prepend Test Note",
"directory": "test",
"content": "# Prepend Test Note\n\nExisting content.",
"tags": "test,prepend",
},
)
# Test prepending content
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "test/prepend-test-note",
"operation": "prepend",
"content": "## Important Update\n\nThis was added at the top.\n\n",
},
)
# Should return successful edit summary
assert len(edit_result.content) == 1
edit_text = edit_result.content[0].text
assert "Edited note (prepend)" in edit_text
assert "Added 5 lines to beginning of note" in edit_text
# Verify the content was prepended after frontmatter
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "test/prepend-test-note",
},
)
content = read_result.content[0].text
assert "## Important Update" in content
assert "This was added at the top." in content
assert "Existing content." in content
# Check that prepended content comes before existing content
prepend_pos = content.find("Important Update")
existing_pos = content.find("Existing content")
assert prepend_pos < existing_pos
@pytest.mark.asyncio
async def test_edit_note_find_replace_operation(mcp_server, app, test_project):
"""Test find and replace operation on an existing note."""
async with Client(mcp_server) as client:
# Create a note with content to replace
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Find Replace Test",
"directory": "test",
"content": """# Find Replace Test
This is version v1.0.0 of the system.
## Notes
- The current version is v1.0.0
- Next version will be v1.1.0
## Changes
v1.0.0 introduces new features.""",
"tags": "test,version",
},
)
# Test find and replace operation (expecting 3 replacements)
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Find Replace Test",
"operation": "find_replace",
"content": "v1.2.0",
"find_text": "v1.0.0",
"expected_replacements": 3,
},
)
# Should return successful edit summary
assert len(edit_result.content) == 1
edit_text = edit_result.content[0].text
assert "Edited note (find_replace)" in edit_text
assert "Find and replace operation completed" in edit_text
# Verify the replacements were made
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "Find Replace Test",
},
)
content = read_result.content[0].text
assert "v1.2.0" in content
assert "v1.0.0" not in content # Should be completely replaced
assert content.count("v1.2.0") == 3 # Should have exactly 3 occurrences
@pytest.mark.asyncio
async def test_edit_note_replace_section_operation(mcp_server, app, test_project):
"""Test replacing content under a specific section header."""
async with Client(mcp_server) as client:
# Create a note with sections
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Section Replace Test",
"directory": "test",
"content": """# Section Replace Test
## Overview
Original overview content.
## Implementation
Old implementation details here.
This will be replaced.
## Future Work
Some future work notes.""",
"tags": "test,section",
},
)
# Test replacing section content
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "test/section-replace-test",
"operation": "replace_section",
"content": """New implementation approach using microservices.
- Service A handles authentication
- Service B manages data processing
- Service C provides API endpoints
All services communicate via message queues.""",
"section": "## Implementation",
},
)
# Should return successful edit summary
assert len(edit_result.content) == 1
edit_text = edit_result.content[0].text
assert "Edited note (replace_section)" in edit_text
assert "Replaced content under section '## Implementation'" in edit_text
# Verify the section was replaced
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "Section Replace Test",
},
)
content = read_result.content[0].text
assert "New implementation approach using microservices" in content
assert "Old implementation details here" not in content
assert "Service A handles authentication" in content
# Other sections should remain unchanged
assert "Original overview content" in content
assert "Some future work notes" in content
@pytest.mark.asyncio
async def test_edit_note_with_observations_and_relations(mcp_server, app, test_project):
"""Test editing a note that has observations and relations, and verify they're updated."""
async with Client(mcp_server) as client:
# Create a complex note with observations and relations
complex_content = """# API Documentation
The API provides REST endpoints for data access.
## Observations
- [feature] User authentication endpoints
- [tech] Built with FastAPI framework
- [status] Currently in beta testing
## Relations
- implements [[Authentication System]]
- documented_in [[API Guide]]
- depends_on [[Database Schema]]
## Endpoints
Current endpoints include user management."""
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "API Documentation",
"directory": "docs",
"content": complex_content,
"tags": "api,docs",
},
)
# Add new content with observations and relations
new_content = """
## New Features
- [feature] Added payment processing endpoints
- [feature] Implemented rate limiting
- [security] Added OAuth2 authentication
## Additional Relations
- integrates_with [[Payment Gateway]]
- secured_by [[OAuth2 Provider]]"""
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "API Documentation",
"operation": "append",
"content": new_content,
},
)
# Should return edit summary with observation and relation counts
assert len(edit_result.content) == 1
edit_text = edit_result.content[0].text
assert "Edited note (append)" in edit_text
assert "## Observations" in edit_text
assert "## Relations" in edit_text
# Should have feature, tech, status, security categories
assert "feature:" in edit_text
assert "security:" in edit_text
assert "tech:" in edit_text
assert "status:" in edit_text
# Verify the content was added and processed
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "API Documentation",
},
)
content = read_result.content[0].text
assert "Added payment processing endpoints" in content
assert "integrates_with [[Payment Gateway]]" in content
@pytest.mark.asyncio
async def test_edit_note_error_handling_note_not_found(mcp_server, app, test_project):
"""Test error handling when using find_replace on a non-existent note."""
async with Client(mcp_server) as client:
# find_replace on a non-existent note should still error
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Non-existent Note",
"operation": "find_replace",
"content": "replacement",
"find_text": "old text",
},
)
# Should return helpful error message
assert len(edit_result.content) == 1
error_text = edit_result.content[0].text
assert "Edit Failed" in error_text
assert "Non-existent Note" in error_text
assert "search_notes(" in error_text
@pytest.mark.asyncio
async def test_edit_note_append_creates_nonexistent_note(mcp_server, app, test_project):
"""append to a non-existent note should auto-create it and make it readable."""
async with Client(mcp_server) as client:
# Append to a note that doesn't exist yet
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "conversations/daily-log",
"operation": "append",
"content": "# Daily Log\n\nFirst entry for today.",
},
)
# Should return a "Created note" summary
assert len(edit_result.content) == 1
edit_text = edit_result.content[0].text
assert "Created note (append)" in edit_text
assert "fileCreated: true" in edit_text
# The note should now be readable
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "conversations/daily-log",
},
)
content = read_result.content[0].text
assert "Daily Log" in content
assert "First entry for today." in content
@pytest.mark.asyncio
async def test_edit_note_prepend_creates_nonexistent_note(mcp_server, app, test_project):
"""prepend to a non-existent note should auto-create it and make it readable."""
async with Client(mcp_server) as client:
# Prepend to a note that doesn't exist yet
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "notes/quick-thought",
"operation": "prepend",
"content": "# Quick Thought\n\nSomething important.",
},
)
# Should return a "Created note" summary
assert len(edit_result.content) == 1
edit_text = edit_result.content[0].text
assert "Created note (prepend)" in edit_text
assert "fileCreated: true" in edit_text
# The note should now be readable
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "notes/quick-thought",
},
)
content = read_result.content[0].text
assert "Quick Thought" in content
assert "Something important." in content
@pytest.mark.asyncio
async def test_edit_note_error_handling_text_not_found(mcp_server, app, test_project):
"""Test error handling when find_text is not found in the note."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Error Test Note",
"directory": "test",
"content": "# Error Test Note\n\nThis note has specific content.",
"tags": "test,error",
},
)
# Try to replace text that doesn't exist
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Error Test Note",
"operation": "find_replace",
"content": "replacement text",
"find_text": "non-existent text",
},
)
# Should return helpful error message
assert len(edit_result.content) == 1
error_text = edit_result.content[0].text
assert "Edit Failed - Text Not Found" in error_text
assert "non-existent text" in error_text
assert "Error Test Note" in error_text
assert "read_note(" in error_text
@pytest.mark.asyncio
async def test_edit_note_error_handling_wrong_replacement_count(mcp_server, app, test_project):
"""Test error handling when expected_replacements doesn't match actual occurrences."""
async with Client(mcp_server) as client:
# Create a note with specific repeated text
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Count Test Note",
"directory": "test",
"content": """# Count Test Note
The word "test" appears here.
This is another test sentence.
Final test of the content.""",
"tags": "test,count",
},
)
# Try to replace "test" but expect wrong count (should be 3, not 5)
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Count Test Note",
"operation": "find_replace",
"content": "example",
"find_text": "test",
"expected_replacements": 5,
},
)
# Should return helpful error message about count mismatch
assert len(edit_result.content) == 1
error_text = edit_result.content[0].text
assert "Edit Failed - Wrong Replacement Count" in error_text
assert "Expected 5 occurrences" in error_text
assert "test" in error_text
assert "expected_replacements=" in error_text
@pytest.mark.asyncio
async def test_edit_note_invalid_operation(mcp_server, app, test_project):
"""Test error handling for invalid operation parameter."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Invalid Op Test",
"directory": "test",
"content": "# Invalid Op Test\n\nSome content.",
"tags": "test",
},
)
# Try to use an invalid operation - this should raise a ToolError
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Invalid Op Test",
"operation": "invalid_operation",
"content": "Some content",
},
)
# Should contain information about invalid operation
error_message = str(exc_info.value)
assert "Invalid operation 'invalid_operation'" in error_message
assert "append, prepend, find_replace, replace_section" in error_message
@pytest.mark.asyncio
async def test_edit_note_missing_required_parameters(mcp_server, app, test_project):
"""Test error handling when required parameters are missing."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Param Test Note",
"directory": "test",
"content": "# Param Test Note\n\nContent here.",
"tags": "test",
},
)
# Try find_replace without find_text parameter - this should raise a ToolError
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Param Test Note",
"operation": "find_replace",
"content": "replacement",
# Missing find_text parameter
},
)
# Should contain information about missing parameter
error_message = str(exc_info.value)
assert "find_text parameter is required for find_replace operation" in error_message
@pytest.mark.asyncio
async def test_edit_note_special_characters_in_content(mcp_server, app, test_project):
"""Test editing notes with special characters, Unicode, and markdown formatting."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Special Chars Test",
"directory": "test",
"content": "# Special Chars Test\n\nBasic content here.",
"tags": "test,unicode",
},
)
# Add content with special characters and Unicode
special_content = """
## Unicode Section 🚀
This section contains:
- Emojis: 🎉 💡 ⚡ 🔥
- Languages: 测试中文 Tëst Übër
- Math symbols: ∑∏∂∇∆Ω ≠≤≥ ∞
- Special markdown: `code` **bold** *italic*
- URLs: https://example.com/path?param=value&other=123
- Code blocks:
```python
def test_function():
return "Hello, 世界!"
```
## Observations
- [unicode] Unicode characters preserved ✓
- [markdown] Formatting maintained 📝
## Relations
- documented_in [[Unicode Standards]]"""
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Special Chars Test",
"operation": "append",
"content": special_content,
},
)
# Should successfully handle special characters
assert len(edit_result.content) == 1
edit_text = edit_result.content[0].text
assert "Edited note (append)" in edit_text
assert "## Observations" in edit_text
assert "unicode:" in edit_text
assert "markdown:" in edit_text
# Verify the special content was added correctly
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "Special Chars Test",
},
)
content = read_result.content[0].text
assert "🚀" in content
assert "测试中文" in content
assert "∑∏∂∇∆Ω" in content
assert "def test_function():" in content
assert "[[Unicode Standards]]" in content
@pytest.mark.asyncio
async def test_edit_note_using_different_identifiers(mcp_server, app, test_project):
"""Test editing notes using different identifier formats (title, permalink, folder/title)."""
async with Client(mcp_server) as client:
# Create a note
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Identifier Test Note",
"directory": "docs",
"content": "# Identifier Test Note\n\nOriginal content.",
"tags": "test,identifier",
},
)
# Test editing by title
edit_result1 = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Identifier Test Note", # by title
"operation": "append",
"content": "\n\nEdited by title.",
},
)
assert "Edited note (append)" in edit_result1.content[0].text
# Test editing by permalink
edit_result2 = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "docs/identifier-test-note", # by permalink
"operation": "append",
"content": "\n\nEdited by permalink.",
},
)
assert "Edited note (append)" in edit_result2.content[0].text
# Test editing by folder/title format
edit_result3 = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "docs/Identifier Test Note", # by folder/title
"operation": "append",
"content": "\n\nEdited by folder/title.",
},
)
assert "Edited note (append)" in edit_result3.content[0].text
# Verify all edits were applied
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "docs/identifier-test-note",
},
)
content = read_result.content[0].text
assert "Edited by title." in content
assert "Edited by permalink." in content
assert "Edited by folder/title." in content
@pytest.mark.asyncio
async def test_edit_note_append_autocreate_does_not_fuzzy_match(mcp_server, app, test_project):
"""Reproduces #649: edit_note append must auto-create, not fuzzy-match to an existing note.
Creates two notes, then attempts to append to a nonexistent identifier.
The tool should create a new note, and neither existing note should be modified.
"""
async with Client(mcp_server) as client:
# Create two notes that could be fuzzy-matched
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Routing Test A",
"directory": "test",
"content": "# Routing Test A\n\nContent A.",
},
)
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Routing Test B",
"directory": "test",
"content": "# Routing Test B\n\nContent B.",
},
)
# Attempt to edit a nonexistent note — should error, not silently edit A or B
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Routing Test NONEXISTENT",
"operation": "append",
"content": "\n\nThis should NOT appear in any note.",
},
)
edit_text = edit_result.content[0].text
# append to nonexistent creates a new note — verify it did NOT edit A or B
assert "Created note (append)" in edit_text
assert "fileCreated: true" in edit_text
# Verify neither A nor B was modified
read_a = await client.call_tool(
"read_note",
{"project": test_project.name, "identifier": "Routing Test A"},
)
content_a = read_a.content[0].text
assert "Content A" in content_a
assert "This should NOT appear" not in content_a
read_b = await client.call_tool(
"read_note",
{"project": test_project.name, "identifier": "Routing Test B"},
)
content_b = read_b.content[0].text
assert "Content B" in content_b
assert "This should NOT appear" not in content_b
# Now test find_replace on nonexistent — should error
edit_result2 = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Routing Test NONEXISTENT AGAIN",
"operation": "find_replace",
"content": "replaced",
"find_text": "Content",
},
)
error_text = edit_result2.content[0].text
assert "Edit Failed" in error_text
@pytest.mark.asyncio
async def test_edit_note_recovers_file_on_disk_not_indexed(mcp_server, app, test_project):
"""edit_note should index and edit a markdown file written directly to disk (#581).
Common flow: a file is written straight to the project directory and edit_note is
called before the watcher indexes it. The tool must recover by indexing the single
file and retrying resolution instead of failing with "Entity not found".
"""
note_path = Path(test_project.path) / "direct" / "disk-note.md"
note_path.parent.mkdir(parents=True, exist_ok=True)
note_path.write_text("# Disk Note\n\nstatus: draft\n", encoding="utf-8")
async with Client(mcp_server) as client:
edit_result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "direct/disk-note",
"operation": "find_replace",
"content": "status: final",
"find_text": "status: draft",
},
)
edit_text = edit_result.content[0].text
assert "Edited note (find_replace)" in edit_text
read_result = await client.call_tool(
"read_note",
{"project": test_project.name, "identifier": "direct/disk-note"},
)
content = read_result.content[0].text
assert "status: final" in content
assert "status: draft" not in content