mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
195229f78e
In cloud mode, ConfigManager has no local config file so default_project always returned None. Add async get_default_project_name() on ProjectService that falls back to the database is_default flag when ConfigManager returns None. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
426 lines
17 KiB
Python
426 lines
17 KiB
Python
"""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
|
|
|
|
|
|
@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"
|
|
assert status_response.new_project.external_id == test_project.external_id
|
|
# Normalize paths for cross-platform comparison (Windows uses backslashes, API returns forward slashes)
|
|
assert Path(status_response.new_project.path) == Path(new_path)
|
|
assert status_response.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
|
|
assert status_response.new_project.external_id == created_project.external_id
|
|
assert status_response.new_project.is_default is True
|
|
assert status_response.old_project.external_id == test_project.external_id
|
|
assert status_response.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"
|
|
assert status_response.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_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"
|