"""Tests for cloud restore CLI commands.
SPEC-29 Phase 3: Tests for restore command.
"""
from unittest.mock import Mock, patch
import httpx
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": [
{"key": "notes/project.md", "size": 1024, "last_modified": "2025-01-18T12:00:00Z"}
],
"prefix": "notes/project.md",
"snapshot_version": "12345",
}
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