diff --git a/src/basic_memory/cli/commands/cloud/__init__.py b/src/basic_memory/cli/commands/cloud/__init__.py index 1b6146e2..0b471e0b 100644 --- a/src/basic_memory/cli/commands/cloud/__init__.py +++ b/src/basic_memory/cli/commands/cloud/__init__.py @@ -1,6 +1,16 @@ """Cloud commands package.""" +from basic_memory.cli.app import cloud_app + # Import all commands to register them with typer from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403 from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers, get_cloud_config # noqa: F401 from basic_memory.cli.commands.cloud.upload_command import * # noqa: F401,F403 + +# Register snapshot sub-command group +from basic_memory.cli.commands.cloud.snapshot import snapshot_app + +cloud_app.add_typer(snapshot_app, name="snapshot") + +# Register restore command (directly on cloud_app via decorator) +from basic_memory.cli.commands.cloud.restore import restore # noqa: F401 diff --git a/src/basic_memory/cli/commands/cloud/restore.py b/src/basic_memory/cli/commands/cloud/restore.py new file mode 100644 index 00000000..a5f2ec5f --- /dev/null +++ b/src/basic_memory/cli/commands/cloud/restore.py @@ -0,0 +1,157 @@ +"""Restore CLI commands for Basic Memory Cloud. + +SPEC-29 Phase 3: CLI commands for restoring files from Tigris bucket snapshots. +""" + +import asyncio + +import typer +from rich.console import Console + +from basic_memory.cli.app import cloud_app +from basic_memory.cli.commands.cloud.api_client import ( + CloudAPIError, + SubscriptionRequiredError, + make_api_request, +) +from basic_memory.config import ConfigManager + +console = Console() + + +@cloud_app.command("restore") +def restore( + path: str = typer.Argument( + ..., + help="Path to restore (file or folder, e.g., 'notes/project.md' or 'research/')", + ), + snapshot_id: str = typer.Option( + ..., + "--snapshot", + "-s", + help="ID of the snapshot to restore from", + ), + force: bool = typer.Option( + False, + "--force", + "-f", + help="Skip confirmation prompt", + ), +) -> None: + """Restore a file or folder from a snapshot. + + This command restores files from a previous snapshot to the current bucket. + The restored files will overwrite any existing files at the same path. + + Examples: + bm cloud restore notes/project.md --snapshot abc123 + bm cloud restore research/ --snapshot abc123 + bm cloud restore notes/project.md --snapshot abc123 --force + """ + + async def _restore(): + try: + config_manager = ConfigManager() + config = config_manager.config + host_url = config.cloud_host.rstrip("/") + + # Normalize path - remove leading slash if present + normalized_path = path.lstrip("/") + + if not force: + # Show what will be restored + console.print(f"[blue]Preparing to restore from snapshot {snapshot_id}[/blue]") + console.print(f" Path: {normalized_path}") + + # Try to browse the snapshot to show what files will be affected + try: + browse_url = f"{host_url}/api/bucket-snapshots/{snapshot_id}/browse" + if normalized_path: + browse_url += f"?prefix={normalized_path}" + + response = await make_api_request( + method="GET", + url=browse_url, + ) + data = response.json() + files = data.get("files", []) + + if files: + if len(files) <= 10: + console.print("\n Files to restore:") + for file_path in files: + console.print(f" - {file_path}") + else: + console.print(f"\n {len(files)} files will be restored") + console.print(" First 5 files:") + for file_path in files[:5]: + console.print(f" - {file_path}") + console.print(f" ... and {len(files) - 5} more") + else: + console.print( + f"\n[yellow]No files found matching '{normalized_path}' " + f"in snapshot[/yellow]" + ) + raise typer.Exit(0) + + except CloudAPIError as browse_error: + if browse_error.status_code == 404: + console.print(f"[red]Snapshot not found: {snapshot_id}[/red]") + raise typer.Exit(1) + # If browse fails for other reasons, proceed with confirmation anyway + pass + + console.print( + "\n[yellow]Warning: Restored files will overwrite existing files![/yellow]" + ) + confirmed = typer.confirm("\nProceed with restore?") + if not confirmed: + console.print("[yellow]Restore cancelled[/yellow]") + raise typer.Exit(0) + + console.print(f"[blue]Restoring from snapshot {snapshot_id}...[/blue]") + + response = await make_api_request( + method="POST", + url=f"{host_url}/api/bucket-snapshots/{snapshot_id}/restore", + json_data={"path": normalized_path}, + ) + + data = response.json() + restored_files = data.get("restored", []) + returned_snapshot_id = data.get("snapshot_id", snapshot_id) + + if restored_files: + console.print(f"[green]Successfully restored {len(restored_files)} file(s)[/green]") + if len(restored_files) <= 10: + for file_path in restored_files: + console.print(f" - {file_path}") + else: + console.print(" First 5 restored files:") + for file_path in restored_files[:5]: + console.print(f" - {file_path}") + console.print(f" ... and {len(restored_files) - 5} more") + console.print(f"\n[dim]Snapshot ID: {returned_snapshot_id}[/dim]") + else: + console.print("[yellow]No files were restored[/yellow]") + console.print(f"[dim]No files matching '{normalized_path}' found in snapshot[/dim]") + + except typer.Exit: + # Re-raise typer.Exit without modification - it's used for clean exits + raise + except SubscriptionRequiredError as e: + console.print("\n[red]Subscription Required[/red]\n") + console.print(f"[yellow]{e.args[0]}[/yellow]\n") + console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n") + raise typer.Exit(1) + except CloudAPIError as e: + if e.status_code == 404: + console.print(f"[red]Snapshot not found: {snapshot_id}[/red]") + else: + console.print(f"[red]Failed to restore: {e}[/red]") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Unexpected error: {e}[/red]") + raise typer.Exit(1) + + asyncio.run(_restore()) diff --git a/src/basic_memory/cli/commands/cloud/snapshot.py b/src/basic_memory/cli/commands/cloud/snapshot.py new file mode 100644 index 00000000..22b72f59 --- /dev/null +++ b/src/basic_memory/cli/commands/cloud/snapshot.py @@ -0,0 +1,367 @@ +"""Snapshot CLI commands for Basic Memory Cloud. + +SPEC-29 Phase 3: CLI commands for managing Tigris bucket snapshots. +""" + +import asyncio +from datetime import datetime +from typing import Optional + +import typer +from rich.console import Console +from rich.table import Table + +from basic_memory.cli.commands.cloud.api_client import ( + CloudAPIError, + SubscriptionRequiredError, + make_api_request, +) +from basic_memory.config import ConfigManager + +console = Console() +snapshot_app = typer.Typer(help="Manage bucket snapshots") + + +def _format_timestamp(iso_timestamp: str) -> str: + """Format ISO timestamp to a human-readable format.""" + try: + dt = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, AttributeError): + return iso_timestamp + + +@snapshot_app.command("create") +def create( + description: str = typer.Argument( + ..., + help="Description for the snapshot", + ), +) -> None: + """Create a new bucket snapshot. + + Examples: + bm cloud snapshot create "before major refactor" + bm cloud snapshot create "daily backup" + """ + + async def _create(): + try: + config_manager = ConfigManager() + config = config_manager.config + host_url = config.cloud_host.rstrip("/") + + console.print("[blue]Creating snapshot...[/blue]") + + response = await make_api_request( + method="POST", + url=f"{host_url}/api/bucket-snapshots", + json_data={"description": description}, + ) + + data = response.json() + snapshot_id = data.get("id", "unknown") + snapshot_version = data.get("snapshot_version", "unknown") + created_at = _format_timestamp(data.get("created_at", "")) + + console.print("[green]Snapshot created successfully[/green]") + console.print(f" ID: {snapshot_id}") + console.print(f" Version: {snapshot_version}") + console.print(f" Created: {created_at}") + console.print(f" Description: {description}") + + except SubscriptionRequiredError as e: + console.print("\n[red]Subscription Required[/red]\n") + console.print(f"[yellow]{e.args[0]}[/yellow]\n") + console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n") + raise typer.Exit(1) + except CloudAPIError as e: + console.print(f"[red]Failed to create snapshot: {e}[/red]") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Unexpected error: {e}[/red]") + raise typer.Exit(1) + + asyncio.run(_create()) + + +@snapshot_app.command("list") +def list_snapshots( + limit: int = typer.Option( + 10, + "--limit", + "-l", + help="Maximum number of snapshots to display", + ), +) -> None: + """List all bucket snapshots. + + Examples: + bm cloud snapshot list + bm cloud snapshot list --limit 20 + """ + + async def _list(): + try: + config_manager = ConfigManager() + config = config_manager.config + host_url = config.cloud_host.rstrip("/") + + console.print("[blue]Fetching snapshots...[/blue]") + + response = await make_api_request( + method="GET", + url=f"{host_url}/api/bucket-snapshots", + ) + + data = response.json() + snapshots = data.get("snapshots", []) + total = data.get("total", len(snapshots)) + + if not snapshots: + console.print("[yellow]No snapshots found[/yellow]") + console.print( + '\n[dim]Create a snapshot with: bm cloud snapshot create "description"[/dim]' + ) + return + + # Create a table for displaying snapshots + table = Table(title=f"Bucket Snapshots ({total} total)") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Description", style="white") + table.add_column("Auto", style="dim") + table.add_column("Created", style="green") + + for snapshot in snapshots[:limit]: + snapshot_id = snapshot.get("id", "unknown") + desc = snapshot.get("description") or snapshot.get("name", "-") + auto = "yes" if snapshot.get("auto", False) else "no" + created_at = _format_timestamp(snapshot.get("created_at", "")) + + table.add_row(snapshot_id, desc, auto, created_at) + + console.print(table) + + if total > limit: + console.print( + f"\n[dim]Showing {limit} of {total} snapshots. Use --limit to see more.[/dim]" + ) + + except SubscriptionRequiredError as e: + console.print("\n[red]Subscription Required[/red]\n") + console.print(f"[yellow]{e.args[0]}[/yellow]\n") + console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n") + raise typer.Exit(1) + except CloudAPIError as e: + console.print(f"[red]Failed to list snapshots: {e}[/red]") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Unexpected error: {e}[/red]") + raise typer.Exit(1) + + asyncio.run(_list()) + + +@snapshot_app.command("delete") +def delete( + snapshot_id: str = typer.Argument( + ..., + help="The ID of the snapshot to delete", + ), + force: bool = typer.Option( + False, + "--force", + "-f", + help="Skip confirmation prompt", + ), +) -> None: + """Delete a bucket snapshot. + + Examples: + bm cloud snapshot delete abc123 + bm cloud snapshot delete abc123 --force + """ + + async def _delete(): + try: + config_manager = ConfigManager() + config = config_manager.config + host_url = config.cloud_host.rstrip("/") + + if not force: + # Fetch snapshot details first to show what will be deleted + console.print("[blue]Fetching snapshot details...[/blue]") + try: + response = await make_api_request( + method="GET", + url=f"{host_url}/api/bucket-snapshots/{snapshot_id}", + ) + data = response.json() + desc = data.get("description") or data.get("name", "unnamed") + created_at = _format_timestamp(data.get("created_at", "")) + console.print("\nSnapshot to delete:") + console.print(f" ID: {snapshot_id}") + console.print(f" Description: {desc}") + console.print(f" Created: {created_at}") + except CloudAPIError: + # If we can't fetch details, proceed with confirmation anyway + pass + + confirmed = typer.confirm("\nAre you sure you want to delete this snapshot?") + if not confirmed: + console.print("[yellow]Deletion cancelled[/yellow]") + raise typer.Exit(0) + + console.print("[blue]Deleting snapshot...[/blue]") + + await make_api_request( + method="DELETE", + url=f"{host_url}/api/bucket-snapshots/{snapshot_id}", + ) + + console.print(f"[green]Snapshot {snapshot_id} deleted successfully[/green]") + + except typer.Exit: + # Re-raise typer.Exit without modification - it's used for clean exits + raise + except SubscriptionRequiredError as e: + console.print("\n[red]Subscription Required[/red]\n") + console.print(f"[yellow]{e.args[0]}[/yellow]\n") + console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n") + raise typer.Exit(1) + except CloudAPIError as e: + if e.status_code == 404: + console.print(f"[red]Snapshot not found: {snapshot_id}[/red]") + else: + console.print(f"[red]Failed to delete snapshot: {e}[/red]") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Unexpected error: {e}[/red]") + raise typer.Exit(1) + + asyncio.run(_delete()) + + +@snapshot_app.command("show") +def show( + snapshot_id: str = typer.Argument( + ..., + help="The ID of the snapshot to show", + ), +) -> None: + """Show details of a specific snapshot. + + Examples: + bm cloud snapshot show abc123 + """ + + async def _show(): + try: + config_manager = ConfigManager() + config = config_manager.config + host_url = config.cloud_host.rstrip("/") + + response = await make_api_request( + method="GET", + url=f"{host_url}/api/bucket-snapshots/{snapshot_id}", + ) + + data = response.json() + + console.print("[bold blue]Snapshot Details[/bold blue]") + console.print(f" ID: {data.get('id', 'unknown')}") + console.print(f" Bucket: {data.get('bucket_name', 'unknown')}") + console.print(f" Version: {data.get('snapshot_version', 'unknown')}") + console.print(f" Name: {data.get('name', '-')}") + console.print(f" Description: {data.get('description') or '-'}") + console.print(f" Auto: {'yes' if data.get('auto', False) else 'no'}") + console.print(f" Created: {_format_timestamp(data.get('created_at', ''))}") + + except SubscriptionRequiredError as e: + console.print("\n[red]Subscription Required[/red]\n") + console.print(f"[yellow]{e.args[0]}[/yellow]\n") + console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n") + raise typer.Exit(1) + except CloudAPIError as e: + if e.status_code == 404: + console.print(f"[red]Snapshot not found: {snapshot_id}[/red]") + else: + console.print(f"[red]Failed to get snapshot details: {e}[/red]") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Unexpected error: {e}[/red]") + raise typer.Exit(1) + + asyncio.run(_show()) + + +@snapshot_app.command("browse") +def browse( + snapshot_id: str = typer.Argument( + ..., + help="The ID of the snapshot to browse", + ), + prefix: Optional[str] = typer.Option( + None, + "--prefix", + "-p", + help="Filter files by path prefix (e.g., 'notes/')", + ), +) -> None: + """Browse contents of a snapshot. + + Examples: + bm cloud snapshot browse abc123 + bm cloud snapshot browse abc123 --prefix notes/ + """ + + async def _browse(): + try: + config_manager = ConfigManager() + config = config_manager.config + host_url = config.cloud_host.rstrip("/") + + url = f"{host_url}/api/bucket-snapshots/{snapshot_id}/browse" + if prefix: + url += f"?prefix={prefix}" + + response = await make_api_request( + method="GET", + url=url, + ) + + data = response.json() + files = data.get("files", []) + + if not files: + if prefix: + console.print(f"[yellow]No files found with prefix '{prefix}'[/yellow]") + else: + console.print("[yellow]No files found in snapshot[/yellow]") + return + + console.print(f"[bold blue]Snapshot Contents ({len(files)} files)[/bold blue]") + for file_path in files: + console.print(f" {file_path}") + + console.print( + f"\n[dim]Use 'bm cloud restore --snapshot {snapshot_id}' " + f"to restore files[/dim]" + ) + + except SubscriptionRequiredError as e: + console.print("\n[red]Subscription Required[/red]\n") + console.print(f"[yellow]{e.args[0]}[/yellow]\n") + console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n") + raise typer.Exit(1) + except CloudAPIError as e: + if e.status_code == 404: + console.print(f"[red]Snapshot not found: {snapshot_id}[/red]") + else: + console.print(f"[red]Failed to browse snapshot: {e}[/red]") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Unexpected error: {e}[/red]") + raise typer.Exit(1) + + asyncio.run(_browse()) diff --git a/tests/cli/test_restore_commands.py b/tests/cli/test_restore_commands.py new file mode 100644 index 00000000..f1b2caa8 --- /dev/null +++ b/tests/cli/test_restore_commands.py @@ -0,0 +1,406 @@ +"""Tests for cloud restore CLI commands. + +SPEC-29 Phase 3: Tests for restore command. +""" + +from unittest.mock import Mock, patch + +import httpx +import pytest +from typer.testing import CliRunner + +from basic_memory.cli.app import app +from basic_memory.cli.commands.cloud.api_client import ( + CloudAPIError, + SubscriptionRequiredError, +) + + +class TestRestoreCommand: + """Tests for 'bm cloud restore' command.""" + + def test_restore_file_success_with_force(self): + """Test successful file restoration with --force flag.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "restored": ["notes/project.md"], + "snapshot_id": "snap_123", + } + + async def mock_make_api_request(*args, **kwargs): + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.restore.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.restore.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, + [ + "cloud", + "restore", + "notes/project.md", + "--snapshot", + "snap_123", + "--force", + ], + ) + + assert result.exit_code == 0 + assert "Successfully restored" in result.stdout + assert "notes/project.md" in result.stdout + + def test_restore_folder_success(self): + """Test successful folder restoration.""" + runner = CliRunner() + + mock_restore_response = Mock(spec=httpx.Response) + mock_restore_response.status_code = 200 + mock_restore_response.json.return_value = { + "restored": [ + "research/paper1.md", + "research/paper2.md", + "research/notes.md", + ], + "snapshot_id": "snap_123", + } + + async def mock_make_api_request(*args, **kwargs): + return mock_restore_response + + with patch( + "basic_memory.cli.commands.cloud.restore.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.restore.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, + ["cloud", "restore", "research/", "--snapshot", "snap_123", "--force"], + ) + + assert result.exit_code == 0 + assert "Successfully restored 3 file(s)" in result.stdout + + def test_restore_many_files_truncated_output(self): + """Test restore output is truncated for many files.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "restored": [f"notes/file{i}.md" for i in range(20)], + "snapshot_id": "snap_123", + } + + async def mock_make_api_request(*args, **kwargs): + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.restore.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.restore.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, + ["cloud", "restore", "notes/", "--snapshot", "snap_123", "--force"], + ) + + assert result.exit_code == 0 + assert "Successfully restored 20 file(s)" in result.stdout + assert "and 15 more" in result.stdout + + def test_restore_no_files_found(self): + """Test restore when no files match the path.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "restored": [], + "snapshot_id": "snap_123", + } + + async def mock_make_api_request(*args, **kwargs): + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.restore.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.restore.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, + [ + "cloud", + "restore", + "nonexistent/", + "--snapshot", + "snap_123", + "--force", + ], + ) + + assert result.exit_code == 0 + assert "No files were restored" in result.stdout + + def test_restore_snapshot_not_found(self): + """Test restore from non-existent snapshot.""" + runner = CliRunner() + + async def mock_make_api_request(*args, **kwargs): + raise CloudAPIError("Not found", status_code=404) + + with patch( + "basic_memory.cli.commands.cloud.restore.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.restore.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, + [ + "cloud", + "restore", + "notes/project.md", + "--snapshot", + "snap_nonexistent", + "--force", + ], + ) + + assert result.exit_code == 1 + assert "Snapshot not found" in result.stdout + + def test_restore_subscription_required(self): + """Test restore requires subscription.""" + runner = CliRunner() + + async def mock_make_api_request(*args, **kwargs): + raise SubscriptionRequiredError( + message="Active subscription required", + subscribe_url="https://basicmemory.com/subscribe", + ) + + with patch( + "basic_memory.cli.commands.cloud.restore.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.restore.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, + [ + "cloud", + "restore", + "notes/project.md", + "--snapshot", + "snap_123", + "--force", + ], + ) + + assert result.exit_code == 1 + assert "Subscription Required" in result.stdout + assert "https://basicmemory.com/subscribe" in result.stdout + + def test_restore_cancelled_by_user(self): + """Test restore cancelled by user confirmation.""" + runner = CliRunner() + + # First call is browse, second would be restore (should not happen) + mock_browse_response = Mock(spec=httpx.Response) + mock_browse_response.status_code = 200 + mock_browse_response.json.return_value = {"files": ["notes/project.md"]} + + call_count = 0 + + async def mock_make_api_request(method, url, *args, **kwargs): + nonlocal call_count + call_count += 1 + if "browse" in url: + return mock_browse_response + # Track unexpected calls - the test will verify later + return mock_browse_response + + with patch( + "basic_memory.cli.commands.cloud.restore.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.restore.ConfigManager", + return_value=mock_config_manager, + ): + # Simulate user saying "n" to confirmation + result = runner.invoke( + app, + ["cloud", "restore", "notes/project.md", "--snapshot", "snap_123"], + input="n\n", + ) + + assert result.exit_code == 0 + assert "cancelled" in result.stdout + # Only one call should happen (browse), not the restore POST + assert call_count == 1 + + def test_restore_with_leading_slash_normalized(self): + """Test that leading slashes are stripped from path.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "restored": ["notes/project.md"], + "snapshot_id": "snap_123", + } + + captured_json_data = [] + + async def mock_make_api_request(*args, **kwargs): + if "json_data" in kwargs: + captured_json_data.append(kwargs["json_data"]) + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.restore.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.restore.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, + [ + "cloud", + "restore", + "/notes/project.md", # Leading slash + "--snapshot", + "snap_123", + "--force", + ], + ) + + assert result.exit_code == 0 + # Verify the path was normalized (no leading slash) + assert any( + data.get("path") == "notes/project.md" for data in captured_json_data + ) + + def test_restore_api_error(self): + """Test handling generic API errors during restore.""" + runner = CliRunner() + + async def mock_make_api_request(*args, **kwargs): + raise CloudAPIError("Server error", status_code=500) + + with patch( + "basic_memory.cli.commands.cloud.restore.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.restore.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, + [ + "cloud", + "restore", + "notes/project.md", + "--snapshot", + "snap_123", + "--force", + ], + ) + + assert result.exit_code == 1 + assert "Failed to restore" in result.stdout + + +class TestRestoreCommandHelp: + """Tests for restore command help and usage.""" + + def test_restore_requires_snapshot_option(self): + """Test that --snapshot option is required.""" + runner = CliRunner() + + result = runner.invoke(app, ["cloud", "restore", "notes/project.md"]) + + # Should fail due to missing required option (exit code 2 for usage error) + assert result.exit_code == 2 + # Typer writes the error message to the output + assert "Missing option" in result.output or "--snapshot" in result.output + + def test_restore_requires_path_argument(self): + """Test that path argument is required.""" + runner = CliRunner() + + result = runner.invoke(app, ["cloud", "restore", "--snapshot", "snap_123"]) + + # Should fail due to missing required argument + assert result.exit_code != 0 diff --git a/tests/cli/test_snapshot_commands.py b/tests/cli/test_snapshot_commands.py new file mode 100644 index 00000000..83520b72 --- /dev/null +++ b/tests/cli/test_snapshot_commands.py @@ -0,0 +1,525 @@ +"""Tests for cloud snapshot CLI commands. + +SPEC-29 Phase 3: Tests for snapshot create, list, delete, show, browse commands. +""" + +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest +from typer.testing import CliRunner + +from basic_memory.cli.app import app +from basic_memory.cli.commands.cloud.api_client import ( + CloudAPIError, + SubscriptionRequiredError, +) + + +class TestSnapshotCreateCommand: + """Tests for 'bm cloud snapshot create' command.""" + + def test_create_snapshot_success(self): + """Test successful snapshot creation.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "snap_123", + "bucket_name": "tenant-abc", + "snapshot_version": "1703430000000", + "name": "manual-snapshot", + "description": "before major refactor", + "auto": False, + "created_at": "2024-12-24T12:00:00Z", + } + + async def mock_make_api_request(*args, **kwargs): + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, ["cloud", "snapshot", "create", "before major refactor"] + ) + + assert result.exit_code == 0 + assert "Snapshot created successfully" in result.stdout + assert "snap_123" in result.stdout + assert "before major refactor" in result.stdout + + def test_create_snapshot_subscription_required(self): + """Test snapshot creation requires subscription.""" + runner = CliRunner() + + async def mock_make_api_request(*args, **kwargs): + raise SubscriptionRequiredError( + message="Active subscription required", + subscribe_url="https://basicmemory.com/subscribe", + ) + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, ["cloud", "snapshot", "create", "test snapshot"] + ) + + assert result.exit_code == 1 + assert "Subscription Required" in result.stdout + assert "https://basicmemory.com/subscribe" in result.stdout + + def test_create_snapshot_api_error(self): + """Test handling API errors during snapshot creation.""" + runner = CliRunner() + + async def mock_make_api_request(*args, **kwargs): + raise CloudAPIError("Server error", status_code=500) + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, ["cloud", "snapshot", "create", "test snapshot"] + ) + + assert result.exit_code == 1 + assert "Failed to create snapshot" in result.stdout + + +class TestSnapshotListCommand: + """Tests for 'bm cloud snapshot list' command.""" + + def test_list_snapshots_success(self): + """Test successful snapshot listing.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "snapshots": [ + { + "id": "snap_123", + "name": "snapshot-1", + "description": "first snapshot", + "auto": False, + "created_at": "2024-12-24T12:00:00Z", + }, + { + "id": "snap_456", + "name": "daily-auto", + "description": "daily backup", + "auto": True, + "created_at": "2024-12-23T03:00:00Z", + }, + ], + "total": 2, + } + + async def mock_make_api_request(*args, **kwargs): + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke(app, ["cloud", "snapshot", "list"]) + + assert result.exit_code == 0 + assert "snap_123" in result.stdout + assert "snap_456" in result.stdout + assert "first snapshot" in result.stdout + assert "daily backup" in result.stdout + + def test_list_snapshots_empty(self): + """Test listing when no snapshots exist.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"snapshots": [], "total": 0} + + async def mock_make_api_request(*args, **kwargs): + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke(app, ["cloud", "snapshot", "list"]) + + assert result.exit_code == 0 + assert "No snapshots found" in result.stdout + + def test_list_snapshots_with_limit(self): + """Test listing snapshots with custom limit.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "snapshots": [ + {"id": f"snap_{i}", "name": f"snap-{i}", "auto": False, "created_at": "2024-12-24T12:00:00Z"} + for i in range(20) + ], + "total": 20, + } + + async def mock_make_api_request(*args, **kwargs): + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke(app, ["cloud", "snapshot", "list", "--limit", "5"]) + + assert result.exit_code == 0 + # Should show message about more snapshots available + assert "Showing 5 of 20" in result.stdout + + +class TestSnapshotDeleteCommand: + """Tests for 'bm cloud snapshot delete' command.""" + + def test_delete_snapshot_success_with_force(self): + """Test successful snapshot deletion with --force flag.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {} + + async def mock_make_api_request(*args, **kwargs): + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, ["cloud", "snapshot", "delete", "snap_123", "--force"] + ) + + assert result.exit_code == 0 + assert "deleted successfully" in result.stdout + + def test_delete_snapshot_not_found(self): + """Test deletion of non-existent snapshot.""" + runner = CliRunner() + + async def mock_make_api_request(*args, **kwargs): + raise CloudAPIError("Not found", status_code=404) + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, ["cloud", "snapshot", "delete", "snap_nonexistent", "--force"] + ) + + assert result.exit_code == 1 + assert "Snapshot not found" in result.stdout + + def test_delete_snapshot_cancelled(self): + """Test snapshot deletion cancelled by user.""" + runner = CliRunner() + + # Mock successful GET for snapshot details + mock_get_response = Mock(spec=httpx.Response) + mock_get_response.status_code = 200 + mock_get_response.json.return_value = { + "id": "snap_123", + "description": "test snapshot", + "created_at": "2024-12-24T12:00:00Z", + } + + call_count = 0 + + async def mock_make_api_request(*args, **kwargs): + nonlocal call_count + call_count += 1 + method = kwargs.get("method", args[0] if args else None) + if method == "GET": + return mock_get_response + # Track unexpected calls + return mock_get_response + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + # Simulate user saying "n" to confirmation + result = runner.invoke( + app, ["cloud", "snapshot", "delete", "snap_123"], input="n\n" + ) + + assert result.exit_code == 0 + assert "cancelled" in result.stdout + # Only one call should happen (GET for details), not the DELETE + assert call_count == 1 + + +class TestSnapshotShowCommand: + """Tests for 'bm cloud snapshot show' command.""" + + def test_show_snapshot_success(self): + """Test showing snapshot details.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "snap_123", + "bucket_name": "tenant-abc", + "snapshot_version": "1703430000000", + "name": "test-snapshot", + "description": "test description", + "auto": False, + "created_at": "2024-12-24T12:00:00Z", + } + + async def mock_make_api_request(*args, **kwargs): + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke(app, ["cloud", "snapshot", "show", "snap_123"]) + + assert result.exit_code == 0 + assert "snap_123" in result.stdout + assert "tenant-abc" in result.stdout + assert "test description" in result.stdout + + def test_show_snapshot_not_found(self): + """Test showing non-existent snapshot.""" + runner = CliRunner() + + async def mock_make_api_request(*args, **kwargs): + raise CloudAPIError("Not found", status_code=404) + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, ["cloud", "snapshot", "show", "snap_nonexistent"] + ) + + assert result.exit_code == 1 + assert "Snapshot not found" in result.stdout + + +class TestSnapshotBrowseCommand: + """Tests for 'bm cloud snapshot browse' command.""" + + def test_browse_snapshot_success(self): + """Test browsing snapshot contents.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "files": [ + "notes/project.md", + "notes/ideas.md", + "research/paper.md", + ] + } + + async def mock_make_api_request(*args, **kwargs): + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke(app, ["cloud", "snapshot", "browse", "snap_123"]) + + assert result.exit_code == 0 + assert "notes/project.md" in result.stdout + assert "notes/ideas.md" in result.stdout + assert "research/paper.md" in result.stdout + + def test_browse_snapshot_with_prefix(self): + """Test browsing snapshot with prefix filter.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "files": [ + "notes/project.md", + "notes/ideas.md", + ] + } + + async def mock_make_api_request(*args, **kwargs): + # Verify prefix is in the URL + url = args[1] if len(args) > 1 else kwargs.get("url", "") + assert "prefix=notes/" in url + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke( + app, ["cloud", "snapshot", "browse", "snap_123", "--prefix", "notes/"] + ) + + assert result.exit_code == 0 + + def test_browse_snapshot_empty(self): + """Test browsing snapshot with no files.""" + runner = CliRunner() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"files": []} + + async def mock_make_api_request(*args, **kwargs): + return mock_response + + with patch( + "basic_memory.cli.commands.cloud.snapshot.make_api_request", + side_effect=mock_make_api_request, + ): + mock_config = Mock() + mock_config.cloud_host = "https://cloud.example.com" + mock_config_manager = Mock() + mock_config_manager.config = mock_config + + with patch( + "basic_memory.cli.commands.cloud.snapshot.ConfigManager", + return_value=mock_config_manager, + ): + result = runner.invoke(app, ["cloud", "snapshot", "browse", "snap_123"]) + + assert result.exit_code == 0 + assert "No files found" in result.stdout