From 5aef758ce806c8fc303805f60d29d9494e24a829 Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 12 Jun 2026 10:03:26 -0500 Subject: [PATCH] fix(api): point fresh installs at project setup when resolve finds an empty projects table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../api/v2/routers/project_router.py | 15 +++++++- tests/api/v2/test_project_router.py | 35 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/basic_memory/api/v2/routers/project_router.py b/src/basic_memory/api/v2/routers/project_router.py index bfec8303..9ba2adbf 100644 --- a/src/basic_memory/api/v2/routers/project_router.py +++ b/src/basic_memory/api/v2/routers/project_router.py @@ -320,7 +320,20 @@ async def resolve_project_identifier( ) if not project: - raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'") + detail = f"Project not found: '{data.identifier}'" + # Trigger: resolution missed and the projects table is empty. + # Why: a fresh install bootstraps config.json's default project before any + # reconciliation has created database rows (the one-shot CLI never runs + # the server lifespan), so the first read fails on the configured + # default and the bare not-found message reads as a broken install + # rather than a missing first-run step (#974 follow-up). + # Outcome: the error names the setup command instead. + if not await project_repository.find_all(limit=1, use_load_options=False): + detail = ( + f"{detail}. No projects are set up yet — run " + "'basic-memory project add ' to create one." + ) + raise HTTPException(status_code=404, detail=detail) return ProjectResolveResponse( external_id=project.external_id, diff --git a/tests/api/v2/test_project_router.py b/tests/api/v2/test_project_router.py index 763fc0bb..9b46f681 100644 --- a/tests/api/v2/test_project_router.py +++ b/tests/api/v2/test_project_router.py @@ -445,6 +445,41 @@ async def test_resolve_project_not_found(client: AsyncClient, v2_projects_url): 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."""