diff --git a/src/basic_memory/api/v2/routers/project_router.py b/src/basic_memory/api/v2/routers/project_router.py index aa7e8240..5844c874 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 27363648..ceccebf4 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."""