Skip to content

Commit f800c5d

Browse files
committed
test(cli): cover workspace sync guard branches
Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent 4ac4950 commit f800c5d

2 files changed

Lines changed: 129 additions & 8 deletions

File tree

src/basic_memory/cli/commands/cloud/project_sync.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
)
2424
from basic_memory.cli.commands.command_utils import run_with_cleanup
2525
from basic_memory.cli.commands.routing import force_routing
26-
from basic_memory.config import ConfigManager, ProjectEntry
26+
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectEntry
2727
from basic_memory.mcp.async_client import get_client
2828
from basic_memory.mcp.clients import ProjectClient
2929
from basic_memory.mcp.project_context import get_available_workspaces
@@ -44,14 +44,14 @@
4444
# --- Shared helpers ---
4545

4646

47-
def _has_cloud_credentials(config) -> bool:
47+
def _has_cloud_credentials(config: BasicMemoryConfig) -> bool:
4848
"""Return whether cloud credentials are available (API key or OAuth token)."""
4949
from basic_memory.config import has_cloud_credentials
5050

5151
return has_cloud_credentials(config)
5252

5353

54-
def _require_cloud_credentials(config) -> None:
54+
def _require_cloud_credentials(config: BasicMemoryConfig) -> None:
5555
"""Exit with actionable guidance when cloud credentials are missing."""
5656
if _has_cloud_credentials(config):
5757
return
@@ -61,7 +61,7 @@ def _require_cloud_credentials(config) -> None:
6161
raise typer.Exit(1)
6262

6363

64-
async def _get_workspace_for_project(name: str, config) -> WorkspaceInfo:
64+
async def _get_workspace_for_project(name: str, config: BasicMemoryConfig) -> WorkspaceInfo:
6565
"""Resolve the cloud workspace targeted by a project-scoped sync command."""
6666
workspaces = await get_available_workspaces()
6767
if not workspaces:
@@ -94,7 +94,7 @@ async def _get_workspace_for_project(name: str, config) -> WorkspaceInfo:
9494
)
9595

9696

97-
def _require_personal_workspace(name: str, config) -> WorkspaceInfo:
97+
def _require_personal_workspace(name: str, config: BasicMemoryConfig) -> WorkspaceInfo:
9898
"""Exit before rclone work when the target workspace is not personal."""
9999
try:
100100
workspace = run_with_cleanup(_get_workspace_for_project(name, config))
@@ -120,7 +120,7 @@ async def _get_cloud_project(name: str) -> ProjectItem | None:
120120

121121

122122
def _get_sync_project(
123-
name: str, config, project_data: ProjectItem
123+
name: str, config: BasicMemoryConfig, project_data: ProjectItem
124124
) -> tuple[SyncProject, str | None]:
125125
"""Build a SyncProject and resolve local_sync_path from config.
126126

tests/cli/cloud/test_project_sync_command.py

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from types import SimpleNamespace
55

66
import pytest
7+
import typer
78
from typer.testing import CliRunner
89

910
from basic_memory.cli.app import app
@@ -161,6 +162,124 @@ def test_require_personal_workspace_allows_personal_workspace(monkeypatch, confi
161162
assert workspace.tenant_id == "personal-tenant"
162163

163164

165+
def test_require_personal_workspace_uses_default_workspace(monkeypatch, config_manager):
166+
"""When no project workspace is set, the single cloud default is used."""
167+
project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
168+
169+
config = config_manager.load_config()
170+
config.default_workspace = None
171+
config.projects["research"] = ProjectEntry(path="/tmp/research", mode=ProjectMode.CLOUD)
172+
config_manager.save_config(config)
173+
174+
monkeypatch.setattr(
175+
project_sync_command,
176+
"get_available_workspaces",
177+
lambda: _async_value(
178+
[
179+
_workspace("team-tenant", "organization", "team"),
180+
_workspace("personal-tenant", "personal", "personal", is_default=True),
181+
]
182+
),
183+
)
184+
185+
workspace = project_sync_command._require_personal_workspace("research", config)
186+
187+
assert workspace.tenant_id == "personal-tenant"
188+
189+
190+
def test_require_personal_workspace_uses_single_workspace(monkeypatch, config_manager):
191+
"""A single accessible workspace is unambiguous even when none is marked default."""
192+
project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
193+
194+
config = config_manager.load_config()
195+
config.default_workspace = None
196+
config.projects["research"] = ProjectEntry(path="/tmp/research", mode=ProjectMode.CLOUD)
197+
config_manager.save_config(config)
198+
199+
monkeypatch.setattr(
200+
project_sync_command,
201+
"get_available_workspaces",
202+
lambda: _async_value([_workspace("personal-tenant", "personal", "personal")]),
203+
)
204+
205+
workspace = project_sync_command._require_personal_workspace("research", config)
206+
207+
assert workspace.tenant_id == "personal-tenant"
208+
209+
210+
def test_require_personal_workspace_reports_no_accessible_workspaces(monkeypatch, config_manager):
211+
"""Workspace resolution exits with a clear error when the account has no workspaces."""
212+
project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
213+
214+
config = config_manager.load_config()
215+
config.default_workspace = None
216+
config.projects["research"] = ProjectEntry(path="/tmp/research", mode=ProjectMode.CLOUD)
217+
config_manager.save_config(config)
218+
219+
monkeypatch.setattr(
220+
project_sync_command,
221+
"get_available_workspaces",
222+
lambda: _async_value([]),
223+
)
224+
225+
with pytest.raises(typer.Exit) as exc_info:
226+
project_sync_command._require_personal_workspace("research", config)
227+
228+
assert exc_info.value.exit_code == 1
229+
230+
231+
def test_require_personal_workspace_reports_inaccessible_configured_workspace(
232+
monkeypatch, config_manager
233+
):
234+
"""A configured workspace id must be present in the accessible workspace list."""
235+
project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
236+
237+
config = config_manager.load_config()
238+
config.projects["research"] = ProjectEntry(
239+
path="/tmp/research",
240+
mode=ProjectMode.CLOUD,
241+
workspace_id="missing-tenant",
242+
)
243+
config_manager.save_config(config)
244+
245+
monkeypatch.setattr(
246+
project_sync_command,
247+
"get_available_workspaces",
248+
lambda: _async_value([_workspace("personal-tenant", "personal", "personal")]),
249+
)
250+
251+
with pytest.raises(typer.Exit) as exc_info:
252+
project_sync_command._require_personal_workspace("research", config)
253+
254+
assert exc_info.value.exit_code == 1
255+
256+
257+
def test_require_personal_workspace_reports_ambiguous_workspace(monkeypatch, config_manager):
258+
"""Multiple accessible workspaces need an explicit project or account default."""
259+
project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
260+
261+
config = config_manager.load_config()
262+
config.default_workspace = None
263+
config.projects["research"] = ProjectEntry(path="/tmp/research", mode=ProjectMode.CLOUD)
264+
config_manager.save_config(config)
265+
266+
monkeypatch.setattr(
267+
project_sync_command,
268+
"get_available_workspaces",
269+
lambda: _async_value(
270+
[
271+
_workspace("personal-tenant-a", "personal", "personal-a"),
272+
_workspace("personal-tenant-b", "personal", "personal-b"),
273+
]
274+
),
275+
)
276+
277+
with pytest.raises(typer.Exit) as exc_info:
278+
project_sync_command._require_personal_workspace("research", config)
279+
280+
assert exc_info.value.exit_code == 1
281+
282+
164283
def test_bisync_reset_skips_workspace_check_without_credentials(monkeypatch, tmp_path):
165284
"""Resetting local bisync state stays harmless when no cloud credentials exist."""
166285
project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
@@ -186,13 +305,15 @@ async def _async_value(value):
186305
return value
187306

188307

189-
def _workspace(tenant_id: str, workspace_type: str, slug: str) -> WorkspaceInfo:
308+
def _workspace(
309+
tenant_id: str, workspace_type: str, slug: str, *, is_default: bool = False
310+
) -> WorkspaceInfo:
190311
return WorkspaceInfo(
191312
tenant_id=tenant_id,
192313
workspace_type=workspace_type,
193314
slug=slug,
194315
name=slug.title(),
195316
role="owner",
196-
is_default=False,
317+
is_default=is_default,
197318
has_active_subscription=True,
198319
)

0 commit comments

Comments
 (0)