From 4c14024341f8fb1ff6c4b387d62cbdf2e722392c Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Thu, 11 Jun 2026 13:45:27 -0500 Subject: [PATCH] fix(api): allow setting default project when none is currently set The v2 set_default_project_by_id handler fetched the current default solely to echo it as old_project in the response, and raised 404 "No default project is currently set" when none existed. That guard (marked # pragma: no cover, never tested) made the bootstrap/recovery case impossible: with no default row in the DB, `bm project default ` always failed -- yet that is exactly the command you reach for when no default is set. ProjectStatusResponse.old_project is already Optional, so the guard served no schema requirement. Remove it and build old_project only when a previous default exists, else pass None. Add a regression test that clears is_default, sets a default via the endpoint, and asserts 200, old_project is None, the new project is default, and a follow-up read-back returns it. Closes #975 Co-Authored-By: Claude Signed-off-by: Drew Cain --- .../api/v2/routers/project_router.py | 30 +++++++++++------- tests/api/v2/test_project_router.py | 31 +++++++++++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/basic_memory/api/v2/routers/project_router.py b/src/basic_memory/api/v2/routers/project_router.py index aa7e82409..5844c874a 100644 --- a/src/basic_memory/api/v2/routers/project_router.py +++ b/src/basic_memory/api/v2/routers/project_router.py @@ -559,12 +559,10 @@ async def set_default_project_by_id( logger.info(f"API v2 request: set_default_project_by_id for project_id={project_id}") try: - # Get the old default project from database + # Get the old default project from database. It may be absent during + # bootstrap/recovery (no default row yet); that is a valid state, not an + # error, so we only echo it back when one exists. default_project = await project_repository.get_default_project() - if not default_project: - raise HTTPException( # pragma: no cover - status_code=404, detail="No default project is currently set" - ) # Get the new default project by external_id new_default_project = await project_repository.get_by_external_id(project_id) @@ -576,17 +574,27 @@ async def set_default_project_by_id( # Set as default using project name (service layer still uses names internally) await project_service.set_default_project(new_default_project.name) - return ProjectStatusResponse( - message=f"Project '{new_default_project.name}' set as default successfully", - status="success", - default=True, - old_project=ProjectItem( + # Trigger: a previous default existed + # Why: ProjectStatusResponse.old_project is Optional; the no-default + # bootstrap case must succeed with old_project=None + # Outcome: response echoes the prior default only when there was one + old_project = ( + ProjectItem( id=default_project.id, external_id=default_project.external_id, name=default_project.name, path=default_project.path, is_default=False, - ), + ) + if default_project + else None + ) + + return ProjectStatusResponse( + message=f"Project '{new_default_project.name}' set as default successfully", + status="success", + default=True, + old_project=old_project, new_project=ProjectItem( id=new_default_project.id, external_id=new_default_project.external_id, diff --git a/tests/api/v2/test_project_router.py b/tests/api/v2/test_project_router.py index 27363648b..ceccebf4b 100644 --- a/tests/api/v2/test_project_router.py +++ b/tests/api/v2/test_project_router.py @@ -137,6 +137,37 @@ async def test_set_default_project_by_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 ` 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."""