"""Tests for V2 project management API routes (ID-based endpoints).""" import tempfile from pathlib import Path import pytest from httpx import AsyncClient from basic_memory.models import Project from basic_memory.schemas.project_info import ProjectItem, ProjectStatusResponse from basic_memory.schemas.v2 import ProjectResolveResponse def _project_item(project: ProjectItem | None) -> ProjectItem: assert project is not None return project @pytest.mark.asyncio async def test_list_projects(client: AsyncClient, test_project: Project, v2_projects_url): """Test listing projects returns default_project from the database.""" response = await client.get(f"{v2_projects_url}/") assert response.status_code == 200 data = response.json() # default_project must be populated from the is_default flag in the database assert data["default_project"] == test_project.name project_names = [p["name"] for p in data["projects"]] assert test_project.name in project_names @pytest.mark.asyncio async def test_get_project_by_id(client: AsyncClient, test_project: Project, v2_projects_url): """Test getting a project by its external_id UUID.""" response = await client.get(f"{v2_projects_url}/{test_project.external_id}") assert response.status_code == 200 project = ProjectItem.model_validate(response.json()) assert project.external_id == test_project.external_id assert project.name == test_project.name assert project.path == test_project.path assert project.is_default == (test_project.is_default or False) @pytest.mark.asyncio async def test_get_project_by_id_not_found(client: AsyncClient, v2_projects_url): """Test getting a non-existent project by external_id returns 404.""" fake_uuid = "00000000-0000-0000-0000-000000000000" response = await client.get(f"{v2_projects_url}/{fake_uuid}") assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_update_project_path_by_id( client: AsyncClient, test_project: Project, v2_projects_url ): """Test updating a project's path by external_id.""" with tempfile.TemporaryDirectory() as tmpdir: new_path = str(Path(tmpdir) / "new-project-location") Path(new_path).mkdir(parents=True, exist_ok=True) update_data = {"path": new_path} response = await client.patch( f"{v2_projects_url}/{test_project.external_id}", json=update_data, ) assert response.status_code == 200 status_response = ProjectStatusResponse.model_validate(response.json()) assert status_response.status == "success" new_project = _project_item(status_response.new_project) old_project = _project_item(status_response.old_project) assert new_project.external_id == test_project.external_id # Normalize paths for cross-platform comparison (Windows uses backslashes, API returns forward slashes) assert Path(new_project.path) == Path(new_path) assert old_project.external_id == test_project.external_id @pytest.mark.asyncio async def test_update_project_invalid_path( client: AsyncClient, test_project: Project, v2_projects_url ): """Test updating with a relative path returns 400.""" update_data = {"path": "relative/path"} response = await client.patch( f"{v2_projects_url}/{test_project.external_id}", json=update_data, ) assert response.status_code == 400 assert "absolute" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_update_project_not_found(client: AsyncClient, v2_projects_url, tmp_path): """Test updating a non-existent project returns 404.""" fake_uuid = "00000000-0000-0000-0000-000000000000" # Use tmp_path for cross-platform absolute path compatibility new_path = str(tmp_path / "new-path") update_data = {"path": new_path} response = await client.patch( f"{v2_projects_url}/{fake_uuid}", json=update_data, ) assert response.status_code == 404 @pytest.mark.asyncio async def test_set_default_project_by_id( client: AsyncClient, test_project: Project, v2_projects_url, project_repository, project_service ): """Test setting a project as default by external_id.""" # Create a second project to test setting default await project_service.add_project("second-project", "/tmp/second-project") # Get the created project from the repository to get its external_id created_project = await project_repository.get_by_name("second-project") assert created_project is not None # Set the second project as default response = await client.put(f"{v2_projects_url}/{created_project.external_id}/default") assert response.status_code == 200 status_response = ProjectStatusResponse.model_validate(response.json()) assert status_response.status == "success" assert status_response.default is True new_project = _project_item(status_response.new_project) old_project = _project_item(status_response.old_project) assert new_project.external_id == created_project.external_id assert new_project.is_default is True assert old_project.external_id == test_project.external_id assert old_project.is_default is False @pytest.mark.asyncio async def test_set_default_project_not_found(client: AsyncClient, v2_projects_url): """Test setting a non-existent project as default returns 404.""" fake_uuid = "00000000-0000-0000-0000-000000000000" response = await client.put(f"{v2_projects_url}/{fake_uuid}/default") assert response.status_code == 404 @pytest.mark.asyncio async def test_delete_project_by_id( client: AsyncClient, test_project: Project, v2_projects_url, project_repository, project_service ): """Test deleting a project by external_id.""" # Create a second project since we can't delete the default await project_service.add_project("to-delete", "/tmp/to-delete") # Get the created project from the repository to get its external_id created_project = await project_repository.get_by_name("to-delete") assert created_project is not None # Delete it response = await client.delete(f"{v2_projects_url}/{created_project.external_id}") assert response.status_code == 200 status_response = ProjectStatusResponse.model_validate(response.json()) assert status_response.status == "success" old_project = _project_item(status_response.old_project) assert old_project.external_id == created_project.external_id assert status_response.new_project is None # Verify it's deleted - trying to get it should return 404 response = await client.get(f"{v2_projects_url}/{created_project.external_id}") assert response.status_code == 404 @pytest.mark.asyncio async def test_delete_project_with_delete_notes_param( client: AsyncClient, test_project: Project, v2_projects_url, project_repository, project_service ): """Test deleting a project with delete_notes parameter.""" # Create a project in a temp directory with tempfile.TemporaryDirectory() as tmpdir: project_path = Path(tmpdir) / "test-delete-notes" project_path.mkdir(parents=True, exist_ok=True) # Create a test file in the project test_file = project_path / "test.md" test_file.write_text("Test content") await project_service.add_project("delete-with-notes", str(project_path)) # Get the created project from the repository to get its external_id created_project = await project_repository.get_by_name("delete-with-notes") assert created_project is not None # Delete with delete_notes=true response = await client.delete( f"{v2_projects_url}/{created_project.external_id}?delete_notes=true" ) assert response.status_code == 200 # Verify directory was deleted assert not project_path.exists() @pytest.mark.asyncio async def test_delete_default_project_fails( client: AsyncClient, test_project: Project, v2_projects_url ): """Test that deleting the default project returns 400.""" # test_project is the default project response = await client.delete(f"{v2_projects_url}/{test_project.external_id}") assert response.status_code == 400 assert "default project" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_delete_project_not_found(client: AsyncClient, v2_projects_url): """Test deleting a non-existent project returns 404.""" fake_uuid = "00000000-0000-0000-0000-000000000000" response = await client.delete(f"{v2_projects_url}/{fake_uuid}") assert response.status_code == 404 @pytest.mark.asyncio async def test_v2_project_endpoints_use_id_not_name( client: AsyncClient, test_project: Project, v2_projects_url ): """Verify v2 project endpoints require project external_id UUID, not name.""" # Try using project name instead of external_id - should fail response = await client.get(f"{v2_projects_url}/{test_project.name}") # Should get 404 because name is not a valid project external_id assert response.status_code == 404 @pytest.mark.asyncio async def test_project_id_stability_after_rename( client: AsyncClient, test_project: Project, v2_projects_url, project_repository ): """Test that project external_id remains stable even after renaming.""" original_external_id = test_project.external_id original_name = test_project.name # Get project by external_id response = await client.get(f"{v2_projects_url}/{original_external_id}") assert response.status_code == 200 project_before = ProjectItem.model_validate(response.json()) assert project_before.external_id == original_external_id assert project_before.name == original_name # Even if we renamed the project (not testing rename here, just the concept), # the external_id would stay the same. This test demonstrates the stability. # Re-fetch by same external_id response = await client.get(f"{v2_projects_url}/{original_external_id}") assert response.status_code == 200 project_after = ProjectItem.model_validate(response.json()) assert project_after.external_id == original_external_id @pytest.mark.asyncio async def test_update_project_active_status( client: AsyncClient, test_project: Project, v2_projects_url, project_repository, project_service ): """Test updating a project's active status by external_id.""" # Create a non-default project await project_service.add_project("test-active", "/tmp/test-active") # Get the created project from the repository to get its external_id created_project = await project_repository.get_by_name("test-active") assert created_project is not None # Update active status update_data = {"is_active": False} response = await client.patch( f"{v2_projects_url}/{created_project.external_id}", json=update_data, ) assert response.status_code == 200 status_response = ProjectStatusResponse.model_validate(response.json()) assert status_response.status == "success" @pytest.mark.asyncio async def test_resolve_project_by_name(client: AsyncClient, test_project: Project, v2_projects_url): """Test resolving a project by name returns correct project external_id.""" resolve_data = {"identifier": test_project.name} response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data) assert response.status_code == 200 resolved = ProjectResolveResponse.model_validate(response.json()) assert resolved.external_id == test_project.external_id assert resolved.name == test_project.name assert resolved.path == test_project.path assert resolved.is_default == (test_project.is_default or False) # Resolution method could be "name" or "permalink" depending on whether name == permalink assert resolved.resolution_method in ["name", "permalink"] @pytest.mark.asyncio async def test_resolve_project_by_permalink( client: AsyncClient, test_project: Project, v2_projects_url ): """Test resolving a project by permalink returns correct project external_id.""" # Assume test_project.name can be converted to permalink from basic_memory.utils import generate_permalink project_permalink = generate_permalink(test_project.name) resolve_data = {"identifier": project_permalink} response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data) assert response.status_code == 200 resolved = ProjectResolveResponse.model_validate(response.json()) assert resolved.external_id == test_project.external_id assert resolved.name == test_project.name # Resolution method could be "name" or "permalink" depending on implementation assert resolved.resolution_method in ["name", "permalink"] @pytest.mark.asyncio async def test_resolve_project_by_workspace_qualified_permalink( client: AsyncClient, test_project: Project, v2_projects_url ): """Resolve the workspace/project form shown by MCP disambiguation errors.""" resolve_data = {"identifier": f"personal/{test_project.name}"} response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data) assert response.status_code == 200 resolved = ProjectResolveResponse.model_validate(response.json()) assert resolved.external_id == test_project.external_id assert resolved.name == test_project.name assert resolved.resolution_method == "permalink" @pytest.mark.asyncio async def test_resolve_project_by_id(client: AsyncClient, test_project: Project, v2_projects_url): """Test resolving a project by external_id string returns correct project external_id.""" resolve_data = {"identifier": test_project.external_id} response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data) assert response.status_code == 200 resolved = ProjectResolveResponse.model_validate(response.json()) assert resolved.external_id == test_project.external_id assert resolved.name == test_project.name assert resolved.resolution_method == "external_id" @pytest.mark.asyncio async def test_resolve_project_case_insensitive( client: AsyncClient, test_project: Project, v2_projects_url ): """Test resolving a project by name is case-insensitive.""" resolve_data = {"identifier": test_project.name.upper()} response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data) assert response.status_code == 200 resolved = ProjectResolveResponse.model_validate(response.json()) assert resolved.external_id == test_project.external_id assert resolved.name == test_project.name @pytest.mark.asyncio async def test_resolve_project_not_found(client: AsyncClient, v2_projects_url): """Test resolving a non-existent project returns 404.""" resolve_data = {"identifier": "nonexistent-project"} response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data) assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_resolve_project_empty_identifier(client: AsyncClient, v2_projects_url): """Test resolving with empty identifier returns 422.""" resolve_data = {"identifier": ""} response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data) assert response.status_code == 422 # Validation error # --- Legacy v1 compatibility tests --- @pytest.mark.asyncio async def test_legacy_v1_list_projects_endpoint(client: AsyncClient, test_project: Project): """Test that the legacy /projects/projects endpoint still works for older CLI versions. This endpoint was removed when we migrated to v2 but older versions of basic-memory-cloud CLI still call it for `bm project list`. Note: The route must be without trailing slash to avoid 307 redirects that the cloud proxy doesn't follow. """ # The legacy v1 endpoint was at /projects/projects (no trailing slash) response = await client.get("/projects/projects") assert response.status_code == 200 data = response.json() assert "projects" in data # default_project must be populated, not null assert data["default_project"] == test_project.name project_names = [p["name"] for p in data["projects"]] assert test_project.name in project_names @pytest.mark.asyncio async def test_legacy_v1_add_project_endpoint(client: AsyncClient, test_project: Project): """Test that the legacy POST /projects/projects endpoint still works for older CLI versions. Older versions of basic-memory-cloud CLI call POST /projects/projects to add projects. The legacy route must proxy through to the same handler as v2. Uses the existing test project name+path to exercise the idempotent path (200 OK), which proves the route is connected without needing a full config manager. """ response = await client.post( "/projects/projects", json={ "name": test_project.name, "path": test_project.path, "set_default": False, }, ) # Idempotent: same name + same path returns 200 assert response.status_code == 200 data = response.json() assert data["status"] == "success" assert test_project.name in data["message"] @pytest.mark.asyncio async def test_legacy_v1_sync_config_endpoint(client: AsyncClient): """Test that the legacy POST /projects/config/sync endpoint still works for older CLI versions. Older versions of basic-memory-cloud CLI call POST /projects/config/sync to synchronize projects between config file and database. The route must be reachable. """ response = await client.post("/projects/config/sync") # The handler synchronizes config ↔ DB; should succeed in test environment assert response.status_code == 200 data = response.json() assert data["status"] == "success"