Files
phernandez 515b2c8365 fix(api): point fresh installs at project setup when resolve finds an empty projects table
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>
2026-06-12 10:27:16 -05:00

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"