Files
basicmachines-co-basic-memory/test-int/mcp/test_project_management_integration.py
2026-02-18 22:18:29 -06:00

591 lines
24 KiB
Python

"""
Integration tests for project_management MCP tools.
Tests the complete project management workflow: MCP client -> MCP server -> FastAPI -> project service
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_list_projects_basic_operation(mcp_server, app, test_project):
"""Test basic list_projects operation showing available projects."""
async with Client(mcp_server) as client:
# List all available projects
list_result = await client.call_tool(
"list_memory_projects",
{},
)
# Should return formatted project list
assert len(list_result.content) == 1
list_text = list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# Should show available projects with new session guidance format
assert "Available projects:" in list_text
assert "test-project" in list_text # Our test project
# Check for new session guidance instead of CLI default
assert "Next: Ask which project to use for this session." in list_text
assert "Session reminder: Track the selected project" in list_text
@pytest.mark.asyncio
async def test_project_management_workflow(mcp_server, app, test_project):
"""Test basic project management workflow."""
async with Client(mcp_server) as client:
# List all projects
list_result = await client.call_tool("list_memory_projects", {})
assert "Available projects:" in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert "test-project" in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
@pytest.mark.asyncio
async def test_project_metadata_consistency(mcp_server, app, test_project):
"""Test that project management tools work correctly."""
async with Client(mcp_server) as client:
# Test basic project management tools
# list_projects
list_result = await client.call_tool("list_memory_projects", {})
assert "Available projects:" in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert "test-project" in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
@pytest.mark.asyncio
async def test_create_project_basic_operation(mcp_server, app, test_project, tmp_path):
"""Test creating a new project with basic parameters."""
async with Client(mcp_server) as client:
# Create a new project
create_result = await client.call_tool(
"create_memory_project",
{
"project_name": "test-new-project",
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-test-new-project"
),
},
)
assert len(create_result.content) == 1
create_text = create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# Should show success message and project details
assert "" in create_text # Success indicator
assert "test-new-project" in create_text
assert "Project Details:" in create_text
assert "Name: test-new-project" in create_text
# Check path contains project name (platform-independent)
assert "Path:" in create_text and "test-new-project" in create_text
assert "Project is now available for use" in create_text
# Verify project appears in project list
list_result = await client.call_tool("list_memory_projects", {})
list_text = list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert "test-new-project" in list_text
@pytest.mark.asyncio
async def test_create_project_with_default_flag(mcp_server, app, test_project, tmp_path):
"""Test creating a project and setting it as default."""
async with Client(mcp_server) as client:
# Create a new project and set as default
create_result = await client.call_tool(
"create_memory_project",
{
"project_name": "test-default-project",
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-test-default-project"
),
"set_default": True,
},
)
assert len(create_result.content) == 1
create_text = create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# Should show success and default flag
assert "" in create_text
assert "test-default-project" in create_text
assert "Set as default project" in create_text
# Verify the new project is listed
list_after_create = await client.call_tool("list_memory_projects", {})
assert "test-default-project" in list_after_create.content[0].text # pyright: ignore [reportAttributeAccessIssue]
@pytest.mark.asyncio
async def test_create_project_duplicate_name(mcp_server, app, test_project, tmp_path):
"""Test creating a project with duplicate name is idempotent."""
async with Client(mcp_server) as client:
# First create a project
await client.call_tool(
"create_memory_project",
{
"project_name": "duplicate-test",
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-duplicate-test-1"
),
},
)
# Second create with same name should succeed idempotently
second_result = await client.call_tool(
"create_memory_project",
{
"project_name": "duplicate-test",
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-duplicate-test-2"
),
},
)
second_text = second_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert "already exists" in second_text.lower()
assert "duplicate-test" in second_text
# JSON mode should explicitly report already_exists=true
second_json = await client.call_tool(
"create_memory_project",
{
"project_name": "duplicate-test",
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-duplicate-test-3"
),
"output_format": "json",
},
)
second_json_text = second_json.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert (
'"already_exists":true' in second_json_text
or '"already_exists": true' in second_json_text
)
@pytest.mark.asyncio
async def test_delete_project_basic_operation(mcp_server, app, test_project, tmp_path):
"""Test deleting a project that exists."""
async with Client(mcp_server) as client:
# First create a project to delete
await client.call_tool(
"create_memory_project",
{
"project_name": "to-be-deleted",
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-to-be-deleted"
),
},
)
# Verify it exists
list_result = await client.call_tool("list_memory_projects", {})
assert "to-be-deleted" in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# Delete the project
delete_result = await client.call_tool(
"delete_project",
{
"project_name": "to-be-deleted",
},
)
assert len(delete_result.content) == 1
delete_text = delete_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# Should show success message
assert "" in delete_text
assert "to-be-deleted" in delete_text
assert "removed successfully" in delete_text
assert "Removed project details:" in delete_text
assert "Name: to-be-deleted" in delete_text
assert "Files remain on disk but project is no longer tracked" in delete_text
# Verify project no longer appears in list
list_result_after = await client.call_tool("list_memory_projects", {})
assert "to-be-deleted" not in list_result_after.content[0].text # pyright: ignore [reportAttributeAccessIssue]
@pytest.mark.asyncio
async def test_delete_project_not_found(mcp_server, app, test_project):
"""Test deleting a non-existent project shows error."""
async with Client(mcp_server) as client:
# Try to delete non-existent project
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"delete_project",
{
"project_name": "non-existent-project",
},
)
# Should show error about non-existent project
error_message = str(exc_info.value)
assert "delete_project" in error_message
assert (
"non-existent-project" in error_message
or "not found" in error_message
or "Invalid request" in error_message
)
@pytest.mark.asyncio
async def test_delete_current_project_protection(mcp_server, app, test_project):
"""Test that deleting the current project is prevented."""
async with Client(mcp_server) as client:
# Try to delete the current project (test-project)
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"delete_project",
{
"project_name": "test-project",
},
)
# Should show error about deleting current project
error_message = str(exc_info.value)
assert "delete_project" in error_message
assert (
"currently active" in error_message
or "test-project" in error_message
or "Switch to a different project" in error_message
)
@pytest.mark.asyncio
async def test_project_lifecycle_workflow(mcp_server, app, test_project, tmp_path):
"""Test complete project lifecycle: create, switch, use, delete."""
async with Client(mcp_server) as client:
project_name = "lifecycle-test"
project_path = str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-lifecycle-test"
)
# 1. Create new project
create_result = await client.call_tool(
"create_memory_project",
{
"project_name": project_name,
"project_path": project_path,
},
)
assert "" in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert project_name in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# 2. Create content in the new project
await client.call_tool(
"write_note",
{
"project": project_name,
"title": "Lifecycle Test Note",
"directory": "test",
"content": "# Lifecycle Test\\n\\nThis note tests the project lifecycle.\\n\\n- [test] Lifecycle testing",
"tags": "lifecycle,test",
},
)
# 3. Verify the project exists in the list
list_with_content = await client.call_tool("list_memory_projects", {})
assert project_name in list_with_content.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# 4. Verify we can still access the original test project
test_list = await client.call_tool("list_memory_projects", {})
assert "test-project" in test_list.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# 5. Delete the lifecycle test project
delete_result = await client.call_tool(
"delete_project",
{
"project_name": project_name,
},
)
assert "" in delete_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert f"{project_name}" in delete_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert "removed successfully" in delete_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# 6. Verify project is gone from list
list_result = await client.call_tool("list_memory_projects", {})
assert project_name not in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
@pytest.mark.asyncio
async def test_create_delete_project_edge_cases(mcp_server, app, test_project, tmp_path):
"""Test edge cases for create and delete project operations."""
async with Client(mcp_server) as client:
# Test with special characters and spaces in project name (should be handled gracefully)
special_name = "test project with spaces & symbols!"
# Create project with special characters
create_result = await client.call_tool(
"create_memory_project",
{
"project_name": special_name,
"project_path": str(
tmp_path.parent
/ (tmp_path.name + "-projects")
/ "project-test-project-with-special-chars"
),
},
)
assert "" in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert special_name in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# Verify it appears in list
list_result = await client.call_tool("list_memory_projects", {})
assert special_name in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# Delete it
delete_result = await client.call_tool(
"delete_project",
{
"project_name": special_name,
},
)
assert "" in delete_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert special_name in delete_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# Verify it's gone
list_result_after = await client.call_tool("list_memory_projects", {})
assert special_name not in list_result_after.content[0].text # pyright: ignore [reportAttributeAccessIssue]
@pytest.mark.asyncio
async def test_case_insensitive_project_switching(mcp_server, app, test_project, tmp_path):
"""Test case-insensitive project switching with proper database lookup."""
async with Client(mcp_server) as client:
# Create a project with mixed case name
project_name = "Personal-Project"
create_result = await client.call_tool(
"create_memory_project",
{
"project_name": project_name,
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / f"project-{project_name}"
),
},
)
assert "" in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert project_name in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# Verify project was created with canonical name
list_result = await client.call_tool("list_memory_projects", {})
assert project_name in list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# Test with different case variations
test_cases = [
"personal-project", # all lowercase
"PERSONAL-PROJECT", # all uppercase
"Personal-project", # mixed case 1
"personal-Project", # mixed case 2
]
# Test that project operations work with case-insensitive input
# (Project creation is case-preserving but operations can use different cases)
# Test that we can reference the project with different cases in operations
for test_input in test_cases:
# Test write_note with case-insensitive project reference
write_result = await client.call_tool(
"write_note",
{
"project": test_input, # Use different case
"title": f"Case Test {test_input}",
"directory": "case-test",
"content": f"# Case Test\n\nTesting with {test_input}",
},
)
assert len(write_result.content) == 1
assert f"Case Test {test_input}".lower() in write_result.content[0].text.lower() # pyright: ignore [reportAttributeAccessIssue]
# Clean up
await client.call_tool("delete_project", {"project_name": project_name})
@pytest.mark.asyncio
async def test_case_insensitive_project_operations(mcp_server, app, test_project, tmp_path):
"""Test that all project operations work correctly after case-insensitive switching."""
async with Client(mcp_server) as client:
# Create a project with capital letters
project_name = "CamelCase-Project"
create_result = await client.call_tool(
"create_memory_project",
{
"project_name": project_name,
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / f"project-{project_name}"
),
},
)
assert "" in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# Test that MCP operations work correctly with the project
# 1. Create a note in the project
write_result = await client.call_tool(
"write_note",
{
"project": project_name,
"title": "Case Test Note",
"directory": "case-test",
"content": "# Case Test Note\n\nTesting case-insensitive operations.\n\n- [test] Case insensitive switch\n- relates_to [[Another Note]]",
"tags": "case,test",
},
)
assert len(write_result.content) == 1
assert "Case Test Note" in write_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# 2. Test search works in the project
search_result = await client.call_tool(
"search_notes",
{"project": project_name, "query": "case insensitive"},
)
assert len(search_result.content) == 1
assert "Case Test Note" in search_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# 3. Test read_note works
read_result = await client.call_tool(
"read_note",
{"project": project_name, "identifier": "Case Test Note"},
)
assert len(read_result.content) == 1
assert "Case Test Note" in read_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert "case insensitive" in read_result.content[0].text.lower() # pyright: ignore [reportAttributeAccessIssue]
# Clean up
await client.call_tool("delete_project", {"project_name": project_name})
@pytest.mark.asyncio
async def test_case_insensitive_error_handling(mcp_server, app, test_project):
"""Test error handling for case-insensitive project operations."""
async with Client(mcp_server) as client:
# Test non-existent project with various cases
non_existent_cases = [
"NonExistent",
"non-existent",
"NON-EXISTENT",
"Non-Existent-Project",
]
# Test that operations fail gracefully with non-existent projects
for test_case in non_existent_cases:
# Test that write_note fails with non-existent project
with pytest.raises(Exception):
await client.call_tool(
"write_note",
{
"project": test_case,
"title": "Test Note",
"directory": "test",
"content": "# Test\n\nTest content.",
},
)
@pytest.mark.asyncio
async def test_case_preservation_in_project_list(mcp_server, app, test_project, tmp_path):
"""Test that project names preserve their original case in listings."""
async with Client(mcp_server) as client:
# Create projects with different casing patterns
test_projects = [
"lowercase-project",
"UPPERCASE-PROJECT",
"CamelCase-Project",
"Mixed-CASE-project",
]
# Create all test projects
for project_name in test_projects:
await client.call_tool(
"create_memory_project",
{
"project_name": project_name,
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / f"project-{project_name}"
),
},
)
# List projects and verify each appears with its original case
list_result = await client.call_tool("list_memory_projects", {})
list_text = list_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
for project_name in test_projects:
assert project_name in list_text, f"Project {project_name} not found in list"
# Test each project with exact case (projects are case-sensitive)
for project_name in test_projects:
# Test write_note with exact project name
write_result = await client.call_tool(
"write_note",
{
"project": project_name, # Use exact project name
"title": f"Test Note {project_name}",
"directory": "test",
"content": f"# Test\n\nTesting {project_name}",
},
)
assert len(write_result.content) == 1
result_text = write_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert "successfully" in result_text.lower() or "created" in result_text.lower()
# Clean up - delete test projects
for project_name in test_projects:
await client.call_tool("delete_project", {"project_name": project_name})
@pytest.mark.asyncio
async def test_nested_project_paths_rejected(mcp_server, app, test_project, tmp_path):
"""Test that creating nested project paths is rejected with clear error message."""
async with Client(mcp_server) as client:
# Create a parent project
parent_name = "parent-project"
parent_path = str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-nested-test/parent"
)
await client.call_tool(
"create_memory_project",
{
"project_name": parent_name,
"project_path": parent_path,
},
)
# Try to create a child project nested under the parent
child_name = "child-project"
child_path = str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-nested-test/parent/child"
)
with pytest.raises(Exception) as exc_info:
await client.call_tool(
"create_memory_project",
{
"project_name": child_name,
"project_path": child_path,
},
)
# Verify error message mentions nested paths
error_message = str(exc_info.value)
assert "nested" in error_message.lower()
assert parent_name in error_message or parent_path in error_message
# Clean up parent project
await client.call_tool("delete_project", {"project_name": parent_name})