mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
515b2c8365
On a brand-new config dir, model_post_init bootstraps a 'main' default in config.json but the one-shot CLI path never runs the server-lifespan reconciliation that would create its database row, so the first read fails with a bare "Project not found: 'main'" — which reads as a broken install rather than a missing first-run step. When resolution misses and the projects table is empty, the 404 now names the setup command. Follow-up to #974/#985/#987 (which repair the default during project add but cannot help when the first action is a read). Refs #974. Signed-off-by: phernandez <paul@basicmachines.co>
558 lines
22 KiB
Python
558 lines
22 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.config import ProjectEntry
|
|
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_add_project_response_reflects_promoted_default(
|
|
client: AsyncClient,
|
|
v2_projects_url,
|
|
app_config,
|
|
config_manager,
|
|
config_home,
|
|
project_repository,
|
|
):
|
|
"""Regression #974/#985: POST response should echo persisted default promotion."""
|
|
main_home = config_home / "basic-memory"
|
|
main_home.mkdir(parents=True, exist_ok=True)
|
|
qa_path = config_home / "qa-notes"
|
|
qa_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
fresh_config = app_config.model_copy(
|
|
update={
|
|
"projects": {"main": ProjectEntry(path=str(main_home))},
|
|
"default_project": "main",
|
|
}
|
|
)
|
|
config_manager.save_config(fresh_config)
|
|
|
|
for project in await project_repository.find_all():
|
|
await project_repository.delete(project.id)
|
|
|
|
response = await client.post(
|
|
f"{v2_projects_url}/",
|
|
json={"name": "qa", "path": str(qa_path), "set_default": False},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
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)
|
|
assert new_project.name == "qa"
|
|
assert new_project.is_default is True
|
|
|
|
|
|
@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_when_none_is_set(
|
|
client: AsyncClient, test_project: Project, v2_projects_url, project_repository
|
|
):
|
|
"""Regression for #975: setting a default must succeed when none is set.
|
|
|
|
This is the bootstrap/recovery case: `bm project default <name>` is exactly
|
|
the command reached for when no default exists, so the endpoint must not 404.
|
|
"""
|
|
# Clear any existing default so no row has is_default set.
|
|
await project_repository.update(test_project.id, {"is_default": None})
|
|
assert await project_repository.get_default_project() is None
|
|
|
|
response = await client.put(f"{v2_projects_url}/{test_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
|
|
# No previous default existed, so old_project must be None.
|
|
assert status_response.old_project is None
|
|
new_project = _project_item(status_response.new_project)
|
|
assert new_project.external_id == test_project.external_id
|
|
assert new_project.is_default is True
|
|
|
|
# A follow-up read-back must now return the newly set default.
|
|
default_project = await project_repository.get_default_project()
|
|
assert default_project is not None
|
|
assert default_project.external_id == test_project.external_id
|
|
|
|
|
|
@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_not_found_fresh_install_names_setup_command(
|
|
client: AsyncClient, v2_projects_url, project_repository
|
|
):
|
|
"""#974 follow-up: a fresh install fails its first read with a bare not-found.
|
|
|
|
config.json bootstraps a "main" default before any reconciliation has created
|
|
database rows (the one-shot CLI never runs the server lifespan), so resolving
|
|
the configured default 404s. With an empty projects table the error must point
|
|
at first-run setup instead of reading like a broken install.
|
|
"""
|
|
for project in await project_repository.find_all():
|
|
await project_repository.delete(project.id)
|
|
|
|
response = await client.post(f"{v2_projects_url}/resolve", json={"identifier": "main"})
|
|
|
|
assert response.status_code == 404
|
|
detail = response.json()["detail"]
|
|
assert detail.startswith("Project not found: 'main'")
|
|
assert "basic-memory project add" in detail
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_project_not_found_with_projects_keeps_plain_message(
|
|
client: AsyncClient, test_project: Project, v2_projects_url
|
|
):
|
|
"""A miss against a populated projects table stays a plain not-found."""
|
|
response = await client.post(
|
|
f"{v2_projects_url}/resolve", json={"identifier": "nonexistent-project"}
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "Project not found: 'nonexistent-project'"
|
|
|
|
|
|
@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"
|