mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
a8d034b940
Signed-off-by: phernandez <paul@basicmachines.co>
220 lines
7.9 KiB
Python
220 lines
7.9 KiB
Python
"""Regression tests for move_note edge cases found by the integration bug hunt.
|
|
|
|
Bug #11: move_note did not resolve memory:// URL identifiers, even though its
|
|
docstring advertises them and read_note/edit_note/delete_note all accept them.
|
|
|
|
Bug #12: move_note's structural "<seg>/projects/<seg>/file.md" heuristic wrongly
|
|
rejected legitimate same-project nested moves (e.g. "notes/projects/2025/file.md")
|
|
as cross-project moves. The heuristic was removed; cross-project detection now relies
|
|
on the leading segment matching a known project name plus the post-move outcome
|
|
backstop.
|
|
|
|
These are integration tests: real MCP server -> FastAPI -> database -> filesystem.
|
|
"""
|
|
|
|
import pytest
|
|
from fastmcp import Client
|
|
|
|
|
|
def _result(r):
|
|
return r.structured_content["result"]
|
|
|
|
|
|
# --- Bug #11: memory:// URL resolution ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_move_note_accepts_memory_url(mcp_server, app, test_project):
|
|
"""move_note should accept a memory:// URL identifier like read/edit/delete do."""
|
|
async with Client(mcp_server) as client:
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Memory Url Move",
|
|
"directory": "src",
|
|
"content": "# Memory Url Move\n\nbody",
|
|
"output_format": "json",
|
|
},
|
|
)
|
|
move = await client.call_tool(
|
|
"move_note",
|
|
{
|
|
"project": test_project.name,
|
|
"identifier": "memory://src/memory-url-move",
|
|
"destination_path": "dst/memory-url-move.md",
|
|
"output_format": "json",
|
|
},
|
|
)
|
|
result = _result(move)
|
|
assert result["moved"] is True, (
|
|
f"move_note should resolve memory:// URLs but failed: {result.get('error')}"
|
|
)
|
|
assert result["file_path"] == "dst/memory-url-move.md"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_move_note_bare_permalink_works_control(mcp_server, app, test_project):
|
|
"""Control: bare permalink (no memory://) DOES work for move_note."""
|
|
async with Client(mcp_server) as client:
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Bare Permalink Move",
|
|
"directory": "src",
|
|
"content": "# Bare Permalink Move\n\nbody",
|
|
"output_format": "json",
|
|
},
|
|
)
|
|
move = await client.call_tool(
|
|
"move_note",
|
|
{
|
|
"project": test_project.name,
|
|
"identifier": "src/bare-permalink-move",
|
|
"destination_path": "dst/bare-permalink-move.md",
|
|
"output_format": "json",
|
|
},
|
|
)
|
|
result = _result(move)
|
|
assert result["moved"] is True, result.get("error")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_note_accepts_memory_url_control(mcp_server, app, test_project):
|
|
"""Control: delete_note DOES resolve memory:// URLs (proves the contract)."""
|
|
async with Client(mcp_server) as client:
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Delete Memory Url",
|
|
"directory": "src",
|
|
"content": "# Delete Memory Url\n\nbody",
|
|
"output_format": "json",
|
|
},
|
|
)
|
|
d = await client.call_tool(
|
|
"delete_note",
|
|
{
|
|
"project": test_project.name,
|
|
"identifier": "memory://src/delete-memory-url",
|
|
"output_format": "json",
|
|
},
|
|
)
|
|
assert _result(d)["deleted"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_note_accepts_memory_url_control(mcp_server, app, test_project):
|
|
"""Control: edit_note DOES resolve memory:// URLs (proves the contract)."""
|
|
async with Client(mcp_server) as client:
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Edit Memory Url",
|
|
"directory": "src",
|
|
"content": "# Edit Memory Url\n\nbody",
|
|
"output_format": "json",
|
|
},
|
|
)
|
|
e = await client.call_tool(
|
|
"edit_note",
|
|
{
|
|
"project": test_project.name,
|
|
"identifier": "memory://src/edit-memory-url",
|
|
"operation": "append",
|
|
"content": "\nEDITED",
|
|
"output_format": "json",
|
|
},
|
|
)
|
|
ec = _result(e)
|
|
assert ec.get("error") is None
|
|
assert ec.get("fileCreated") is False
|
|
|
|
|
|
# --- Bug #12: nested 'projects' folder false positive ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_move_into_nested_projects_folder_not_flagged(mcp_server, app, test_project):
|
|
"""A legit nested folder like notes/projects/2025/note.md must NOT be flagged
|
|
as a cross-project move (the structural 'projects'-segment heuristic was removed)."""
|
|
async with Client(mcp_server) as client:
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Nested Projects Note",
|
|
"directory": "inbox",
|
|
"content": "# Nested\n\nbody",
|
|
"output_format": "json",
|
|
},
|
|
)
|
|
move = await client.call_tool(
|
|
"move_note",
|
|
{
|
|
"project": test_project.name,
|
|
"identifier": "inbox/nested-projects-note",
|
|
"destination_path": "notes/projects/2025/nested-projects-note.md",
|
|
"output_format": "json",
|
|
},
|
|
)
|
|
result = _result(move)
|
|
assert result.get("error") != "CROSS_PROJECT_MOVE_NOT_SUPPORTED", (
|
|
"Legit nested 'projects' folder wrongly flagged as cross-project move"
|
|
)
|
|
assert result["moved"] is True, result.get("error")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_move_outcome_mismatch_guidance_uses_actual_landing_path(
|
|
mcp_server, app, test_project, monkeypatch
|
|
):
|
|
"""The text guidance should point agents at the actual post-move path.
|
|
|
|
The real move service stores the requested destination verbatim today, so the
|
|
mismatch backstop is exercised by returning a divergent file_path after the real
|
|
move completes.
|
|
"""
|
|
async with Client(mcp_server) as client:
|
|
await client.call_tool(
|
|
"write_note",
|
|
{
|
|
"project": test_project.name,
|
|
"title": "Outcome Guidance Note",
|
|
"directory": "source",
|
|
"content": "# Outcome Guidance Note\n\nbody",
|
|
"output_format": "json",
|
|
},
|
|
)
|
|
|
|
from basic_memory.mcp.clients import KnowledgeClient
|
|
|
|
real_move_entity = KnowledgeClient.move_entity
|
|
actual_landing_path = "unexpected/outcome-guidance-note.md"
|
|
|
|
async def diverging_move_entity(self, entity_id, destination_path):
|
|
result = await real_move_entity(self, entity_id, destination_path)
|
|
return result.model_copy(update={"file_path": actual_landing_path})
|
|
|
|
monkeypatch.setattr(KnowledgeClient, "move_entity", diverging_move_entity)
|
|
|
|
move = await client.call_tool(
|
|
"move_note",
|
|
{
|
|
"project": test_project.name,
|
|
"identifier": "source/outcome-guidance-note",
|
|
"destination_path": "target/outcome-guidance-note.md",
|
|
},
|
|
)
|
|
|
|
guidance = move.content[0].text
|
|
stale_identifier = "source/outcome-guidance-note"
|
|
assert "Unexpected Result Location" in guidance
|
|
assert f'read_note("{actual_landing_path}")' in guidance
|
|
assert f'delete_note("{actual_landing_path}", project="{test_project.name}")' in guidance
|
|
assert f'read_note("{stale_identifier}")' not in guidance
|
|
assert f'delete_note("{stale_identifier}", project="{test_project.name}")' not in guidance
|