mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
369ad37b3d
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
410 lines
14 KiB
Python
410 lines
14 KiB
Python
"""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
|