mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: Add WebDAV upload command for cloud projects (#356)
Signed-off-by: phernandez <paul@basicmachines.co> Signed-off-by: Pablo Hernandez <pablo@basicmachines.co> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ The Basic Memory Cloud CLI provides seamless integration between local and cloud
|
||||
The cloud CLI enables you to:
|
||||
- **Toggle cloud mode** with `bm cloud login` / `bm cloud logout`
|
||||
- **Use regular commands in cloud mode**: `bm project`, `bm sync`, `bm tool` all work with cloud
|
||||
- **Upload local files** directly to cloud projects via `bm cloud upload`
|
||||
- **Bidirectional sync** with rclone bisync (recommended for most users)
|
||||
- **Direct file access** via rclone mount (alternative workflow)
|
||||
- **Integrity verification** with `bm cloud check`
|
||||
@@ -160,6 +161,69 @@ bm project list
|
||||
|
||||
This Dropbox-like workflow means you don't need to manually coordinate projects between local and cloud.
|
||||
|
||||
### Uploading Local Files
|
||||
|
||||
You can directly upload local files or directories to cloud projects using `bm cloud upload`. This is useful for:
|
||||
- Migrating existing local projects to the cloud
|
||||
- Quickly uploading specific files or directories
|
||||
- One-time bulk uploads without setting up sync
|
||||
|
||||
**Basic Usage:**
|
||||
|
||||
```bash
|
||||
# Upload a directory to existing project
|
||||
bm cloud upload ~/my-notes --project research
|
||||
|
||||
# Upload a single file
|
||||
bm cloud upload important-doc.md --project research
|
||||
```
|
||||
|
||||
**Create Project On-the-Fly:**
|
||||
|
||||
If the target project doesn't exist yet, use `--create-project`:
|
||||
|
||||
```bash
|
||||
# Upload and create project in one step
|
||||
bm cloud upload ~/local-project --project new-research --create-project
|
||||
```
|
||||
|
||||
**Skip Automatic Sync:**
|
||||
|
||||
By default, the command syncs the project after upload to index the files. To skip this:
|
||||
|
||||
```bash
|
||||
# Upload without triggering sync
|
||||
bm cloud upload ~/bulk-data --project archives --no-sync
|
||||
```
|
||||
|
||||
**File Filtering:**
|
||||
|
||||
The upload command respects `.bmignore` and `.gitignore` patterns, automatically excluding:
|
||||
- Hidden files (`.git`, `.DS_Store`)
|
||||
- Build artifacts (`node_modules`, `__pycache__`)
|
||||
- Database files (`*.db`, `*.db-wal`)
|
||||
- Environment files (`.env`)
|
||||
|
||||
To customize what gets uploaded, edit `~/.basic-memory/.bmignore`.
|
||||
|
||||
**Complete Example:**
|
||||
|
||||
```bash
|
||||
# 1. Login to cloud
|
||||
bm cloud login
|
||||
|
||||
# 2. Upload local project (creates project if needed)
|
||||
bm cloud upload ~/Documents/research-notes --project research --create-project
|
||||
|
||||
# 3. Verify upload
|
||||
bm project list
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Files are uploaded directly via WebDAV (no sync setup required)
|
||||
- Uploads are immediate and don't require bisync or mount
|
||||
- Use this for migration or one-time uploads; use `bm sync` for ongoing synchronization
|
||||
|
||||
## File Synchronization
|
||||
|
||||
### The `bm sync` Command (Cloud Mode Aware)
|
||||
@@ -628,6 +692,15 @@ bm cloud check # Full integrity check
|
||||
bm cloud check --one-way # Faster one-way check
|
||||
```
|
||||
|
||||
### File Upload
|
||||
|
||||
```bash
|
||||
# Upload files/directories to cloud projects
|
||||
bm cloud upload <path> --project <name> # Upload to existing project
|
||||
bm cloud upload <path> -p <name> --create-project # Upload and create project
|
||||
bm cloud upload <path> -p <name> --no-sync # Upload without syncing
|
||||
```
|
||||
|
||||
### Direct File Access (Mount)
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ dependencies = [
|
||||
"pyjwt>=2.10.1",
|
||||
"python-dotenv>=1.1.0",
|
||||
"pytest-aio>=1.9.0",
|
||||
"aiofiles>=24.1.0", # Async file I/O
|
||||
"aiofiles>=24.1.0", # Async file I/O
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
# 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 # noqa: F401
|
||||
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
|
||||
|
||||
@@ -12,6 +12,10 @@ from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
|
||||
from basic_memory.cli.commands.cloud.cloud_utils import (
|
||||
create_cloud_project,
|
||||
fetch_cloud_projects,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.rclone_config import (
|
||||
add_tenant_to_rclone_config,
|
||||
)
|
||||
@@ -21,11 +25,7 @@ from basic_memory.ignore_utils import get_bmignore_path, create_default_bmignore
|
||||
from basic_memory.schemas.cloud import (
|
||||
TenantMountInfo,
|
||||
MountCredentials,
|
||||
CloudProjectList,
|
||||
CloudProjectCreateRequest,
|
||||
CloudProjectCreateResponse,
|
||||
)
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -110,24 +110,6 @@ async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
|
||||
raise BisyncError(f"Failed to generate credentials: {e}") from e
|
||||
|
||||
|
||||
async def fetch_cloud_projects() -> CloudProjectList:
|
||||
"""Fetch list of projects from cloud API.
|
||||
|
||||
Returns:
|
||||
CloudProjectList with projects from cloud
|
||||
"""
|
||||
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}/proxy/projects/projects")
|
||||
|
||||
return CloudProjectList.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise BisyncError(f"Failed to fetch cloud projects: {e}") from e
|
||||
|
||||
|
||||
def scan_local_directories(sync_dir: Path) -> list[str]:
|
||||
"""Scan local sync directory for project folders.
|
||||
|
||||
@@ -148,41 +130,6 @@ def scan_local_directories(sync_dir: Path) -> list[str]:
|
||||
return directories
|
||||
|
||||
|
||||
async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
|
||||
"""Create a new project on cloud.
|
||||
|
||||
Args:
|
||||
project_name: Name of project to create
|
||||
|
||||
Returns:
|
||||
CloudProjectCreateResponse with project details from API
|
||||
"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
# Use generate_permalink to ensure consistent naming
|
||||
project_path = generate_permalink(project_name)
|
||||
|
||||
project_data = CloudProjectCreateRequest(
|
||||
name=project_name,
|
||||
path=project_path,
|
||||
set_default=False,
|
||||
)
|
||||
|
||||
response = await make_api_request(
|
||||
method="POST",
|
||||
url=f"{host_url}/proxy/projects/projects",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json_data=project_data.model_dump(),
|
||||
)
|
||||
|
||||
return CloudProjectCreateResponse.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise BisyncError(f"Failed to create cloud project '{project_name}': {e}") from e
|
||||
|
||||
|
||||
def get_bisync_state_path(tenant_id: str) -> Path:
|
||||
"""Get path to bisync state directory."""
|
||||
return Path.home() / ".basic-memory" / "bisync-state" / tenant_id
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Shared utilities for cloud operations."""
|
||||
|
||||
from basic_memory.cli.commands.cloud.api_client import make_api_request
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.schemas.cloud import (
|
||||
CloudProjectList,
|
||||
CloudProjectCreateRequest,
|
||||
CloudProjectCreateResponse,
|
||||
)
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
class CloudUtilsError(Exception):
|
||||
"""Exception raised for cloud utility errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def fetch_cloud_projects() -> CloudProjectList:
|
||||
"""Fetch list of projects from cloud API.
|
||||
|
||||
Returns:
|
||||
CloudProjectList with projects from cloud
|
||||
"""
|
||||
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}/proxy/projects/projects")
|
||||
|
||||
return CloudProjectList.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise CloudUtilsError(f"Failed to fetch cloud projects: {e}") from e
|
||||
|
||||
|
||||
async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
|
||||
"""Create a new project on cloud.
|
||||
|
||||
Args:
|
||||
project_name: Name of project to create
|
||||
|
||||
Returns:
|
||||
CloudProjectCreateResponse with project details from API
|
||||
"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
# Use generate_permalink to ensure consistent naming
|
||||
project_path = generate_permalink(project_name)
|
||||
|
||||
project_data = CloudProjectCreateRequest(
|
||||
name=project_name,
|
||||
path=project_path,
|
||||
set_default=False,
|
||||
)
|
||||
|
||||
response = await make_api_request(
|
||||
method="POST",
|
||||
url=f"{host_url}/proxy/projects/projects",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json_data=project_data.model_dump(),
|
||||
)
|
||||
|
||||
return CloudProjectCreateResponse.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise CloudUtilsError(f"Failed to create cloud project '{project_name}': {e}") from e
|
||||
|
||||
|
||||
async def sync_project(project_name: str) -> None:
|
||||
"""Trigger sync for a specific project on cloud.
|
||||
|
||||
Args:
|
||||
project_name: Name of project to sync
|
||||
"""
|
||||
try:
|
||||
from basic_memory.cli.commands.command_utils import run_sync
|
||||
|
||||
await run_sync(project=project_name)
|
||||
except Exception as e:
|
||||
raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
|
||||
|
||||
|
||||
async def project_exists(project_name: str) -> bool:
|
||||
"""Check if a project exists on cloud.
|
||||
|
||||
Args:
|
||||
project_name: Name of project to check
|
||||
|
||||
Returns:
|
||||
True if project exists, False otherwise
|
||||
"""
|
||||
try:
|
||||
projects = await fetch_cloud_projects()
|
||||
project_names = {p.name for p in projects.projects}
|
||||
return project_name in project_names
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,128 @@
|
||||
"""WebDAV upload functionality for basic-memory projects."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
|
||||
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_put
|
||||
|
||||
|
||||
async def upload_path(local_path: Path, project_name: str) -> bool:
|
||||
"""
|
||||
Upload a file or directory to cloud project via WebDAV.
|
||||
|
||||
Args:
|
||||
local_path: Path to local file or directory
|
||||
project_name: Name of cloud project (destination)
|
||||
|
||||
Returns:
|
||||
True if upload succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Resolve path
|
||||
local_path = local_path.resolve()
|
||||
|
||||
# Check if path exists
|
||||
if not local_path.exists():
|
||||
print(f"Error: Path does not exist: {local_path}")
|
||||
return False
|
||||
|
||||
# Get files to upload
|
||||
if local_path.is_file():
|
||||
files_to_upload = [(local_path, local_path.name)]
|
||||
else:
|
||||
files_to_upload = _get_files_to_upload(local_path)
|
||||
|
||||
if not files_to_upload:
|
||||
print("No files found to upload")
|
||||
return True
|
||||
|
||||
print(f"Found {len(files_to_upload)} file(s) to upload")
|
||||
|
||||
# Upload files using httpx
|
||||
total_bytes = 0
|
||||
|
||||
async with get_client() as client:
|
||||
for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
|
||||
# Build remote path: /webdav/{project_name}/{relative_path}
|
||||
remote_path = f"/webdav/{project_name}/{relative_path}"
|
||||
print(f"Uploading {relative_path} ({i}/{len(files_to_upload)})")
|
||||
|
||||
# Read file content asynchronously
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
content = await f.read()
|
||||
|
||||
# Upload via HTTP PUT to WebDAV endpoint
|
||||
response = await call_put(client, remote_path, content=content)
|
||||
response.raise_for_status()
|
||||
|
||||
total_bytes += file_path.stat().st_size
|
||||
|
||||
# Format size based on magnitude
|
||||
if total_bytes < 1024:
|
||||
size_str = f"{total_bytes} bytes"
|
||||
elif total_bytes < 1024 * 1024:
|
||||
size_str = f"{total_bytes / 1024:.1f} KB"
|
||||
else:
|
||||
size_str = f"{total_bytes / (1024 * 1024):.1f} MB"
|
||||
|
||||
print(f"✓ Upload complete: {len(files_to_upload)} file(s) ({size_str})")
|
||||
return True
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"Upload failed: HTTP {e.response.status_code} - {e.response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Upload failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _get_files_to_upload(directory: Path) -> list[tuple[Path, str]]:
|
||||
"""
|
||||
Get list of files to upload from directory.
|
||||
|
||||
Uses .bmignore and .gitignore patterns for filtering.
|
||||
|
||||
Args:
|
||||
directory: Directory to scan
|
||||
|
||||
Returns:
|
||||
List of (absolute_path, relative_path) tuples
|
||||
"""
|
||||
files = []
|
||||
|
||||
# Load ignore patterns from .bmignore and .gitignore
|
||||
ignore_patterns = load_gitignore_patterns(directory)
|
||||
|
||||
# Walk through directory
|
||||
for root, dirs, filenames in os.walk(directory):
|
||||
root_path = Path(root)
|
||||
|
||||
# Filter directories based on ignore patterns
|
||||
filtered_dirs = []
|
||||
for d in dirs:
|
||||
dir_path = root_path / d
|
||||
if not should_ignore_path(dir_path, directory, ignore_patterns):
|
||||
filtered_dirs.append(d)
|
||||
dirs[:] = filtered_dirs
|
||||
|
||||
# Process files
|
||||
for filename in filenames:
|
||||
file_path = root_path / filename
|
||||
|
||||
# Check if file should be ignored
|
||||
if should_ignore_path(file_path, directory, ignore_patterns):
|
||||
continue
|
||||
|
||||
# Calculate relative path for remote
|
||||
rel_path = file_path.relative_to(directory)
|
||||
# Use forward slashes for WebDAV paths
|
||||
remote_path = str(rel_path).replace("\\", "/")
|
||||
|
||||
files.append((file_path, remote_path))
|
||||
|
||||
return files
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Upload CLI commands for basic-memory projects."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from basic_memory.cli.app import cloud_app
|
||||
from basic_memory.cli.commands.cloud.cloud_utils import (
|
||||
create_cloud_project,
|
||||
project_exists,
|
||||
sync_project,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.upload import upload_path
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@cloud_app.command("upload")
|
||||
def upload(
|
||||
path: Path = typer.Argument(
|
||||
...,
|
||||
help="Path to local file or directory to upload",
|
||||
exists=True,
|
||||
readable=True,
|
||||
resolve_path=True,
|
||||
),
|
||||
project: str = typer.Option(
|
||||
...,
|
||||
"--project",
|
||||
"-p",
|
||||
help="Cloud project name (destination)",
|
||||
),
|
||||
create_project: bool = typer.Option(
|
||||
False,
|
||||
"--create-project",
|
||||
"-c",
|
||||
help="Create project if it doesn't exist",
|
||||
),
|
||||
sync: bool = typer.Option(
|
||||
True,
|
||||
"--sync/--no-sync",
|
||||
help="Sync project after upload (default: true)",
|
||||
),
|
||||
) -> None:
|
||||
"""Upload local files or directories to cloud project via WebDAV.
|
||||
|
||||
Examples:
|
||||
bm cloud upload ~/my-notes --project research
|
||||
bm cloud upload notes.md --project research --create-project
|
||||
bm cloud upload ~/docs --project work --no-sync
|
||||
"""
|
||||
|
||||
async def _upload():
|
||||
# Check if project exists
|
||||
if not await project_exists(project):
|
||||
if create_project:
|
||||
console.print(f"[blue]Creating cloud project '{project}'...[/blue]")
|
||||
try:
|
||||
await create_cloud_project(project)
|
||||
console.print(f"[green]✓ Created project '{project}'[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Failed to create project: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
console.print(
|
||||
f"[red]Project '{project}' does not exist.[/red]\n"
|
||||
f"[yellow]Options:[/yellow]\n"
|
||||
f" 1. Create it first: bm project add {project}\n"
|
||||
f" 2. Use --create-project flag to create automatically"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Perform upload
|
||||
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
|
||||
success = await upload_path(path, project)
|
||||
if not success:
|
||||
console.print("[red]Upload failed[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"[green]✅ Successfully uploaded to '{project}'[/green]")
|
||||
|
||||
# Sync project if requested
|
||||
if sync:
|
||||
console.print(f"[blue]Syncing project '{project}'...[/blue]")
|
||||
try:
|
||||
await sync_project(project)
|
||||
console.print("[green]✓ Project synced[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Sync failed: {e}[/yellow]")
|
||||
console.print("[dim]Files uploaded but may not be indexed yet[/dim]")
|
||||
|
||||
asyncio.run(_upload())
|
||||
@@ -87,6 +87,7 @@ async def get_client() -> AsyncIterator[AsyncClient]:
|
||||
# Auth header set ONCE at client creation
|
||||
proxy_base_url = f"{config.cloud_host}/proxy"
|
||||
logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")
|
||||
print(f"proxy_base_url: {proxy_base_url}")
|
||||
async with AsyncClient(
|
||||
base_url=proxy_base_url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
|
||||
@@ -404,11 +404,9 @@ class TestCloudProjectAutoRegistration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_extracts_directory_names_from_cloud_paths(self):
|
||||
"""Test extraction of directory names from cloud project paths."""
|
||||
from basic_memory.cli.commands.cloud.bisync_commands import fetch_cloud_projects
|
||||
from basic_memory.cli.commands.cloud.cloud_utils import fetch_cloud_projects
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.bisync_commands.make_api_request"
|
||||
) as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"projects": [
|
||||
@@ -435,13 +433,11 @@ class TestCloudProjectAutoRegistration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_cloud_project_generates_permalink(self):
|
||||
"""Test that create_cloud_project generates correct permalink."""
|
||||
from basic_memory.cli.commands.cloud.bisync_commands import create_cloud_project
|
||||
from basic_memory.cli.commands.cloud.cloud_utils import create_cloud_project
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.bisync_commands.make_api_request"
|
||||
) as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.bisync_commands.generate_permalink"
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
|
||||
) as mock_permalink:
|
||||
mock_permalink.return_value = "my-new-project"
|
||||
mock_response = Mock()
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Tests for cloud_utils module."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.cli.commands.cloud.cloud_utils import (
|
||||
CloudUtilsError,
|
||||
create_cloud_project,
|
||||
fetch_cloud_projects,
|
||||
project_exists,
|
||||
sync_project,
|
||||
)
|
||||
|
||||
|
||||
class TestFetchCloudProjects:
|
||||
"""Tests for fetch_cloud_projects()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetches_projects_successfully(self):
|
||||
"""Test successful fetch of cloud projects."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
# Setup config
|
||||
mock_config.return_value.config.cloud_host = "https://example.com"
|
||||
|
||||
# Mock API response
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"projects": [
|
||||
{"name": "Project 1", "path": "/app/data/project-1"},
|
||||
{"name": "Project 2", "path": "/app/data/project-2"},
|
||||
]
|
||||
}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
result = await fetch_cloud_projects()
|
||||
|
||||
# Verify result
|
||||
assert len(result.projects) == 2
|
||||
assert result.projects[0].name == "Project 1"
|
||||
assert result.projects[1].name == "Project 2"
|
||||
|
||||
# Verify API was called correctly
|
||||
mock_request.assert_called_once_with(
|
||||
method="GET", url="https://example.com/proxy/projects/projects"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strips_trailing_slash_from_host(self):
|
||||
"""Test that trailing slash is stripped from cloud_host."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
# Setup config with trailing slash
|
||||
mock_config.return_value.config.cloud_host = "https://example.com/"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"projects": []}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
await fetch_cloud_projects()
|
||||
|
||||
# Verify trailing slash was removed
|
||||
call_args = mock_request.call_args
|
||||
assert call_args[1]["url"] == "https://example.com/proxy/projects/projects"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_error_on_api_failure(self):
|
||||
"""Test that CloudUtilsError is raised on API failure."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
mock_config.return_value.config.cloud_host = "https://example.com"
|
||||
mock_request.side_effect = Exception("API Error")
|
||||
|
||||
with pytest.raises(CloudUtilsError) as exc_info:
|
||||
await fetch_cloud_projects()
|
||||
|
||||
assert "Failed to fetch cloud projects" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_empty_project_list(self):
|
||||
"""Test handling of empty project list."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
mock_config.return_value.config.cloud_host = "https://example.com"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"projects": []}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
result = await fetch_cloud_projects()
|
||||
|
||||
assert len(result.projects) == 0
|
||||
|
||||
|
||||
class TestCreateCloudProject:
|
||||
"""Tests for create_cloud_project()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_project_successfully(self):
|
||||
"""Test successful project creation."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
|
||||
) as mock_permalink:
|
||||
# Setup mocks
|
||||
mock_config.return_value.config.cloud_host = "https://example.com"
|
||||
mock_permalink.return_value = "my-project"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"name": "My Project",
|
||||
"path": "my-project",
|
||||
"message": "Created successfully",
|
||||
}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
result = await create_cloud_project("My Project")
|
||||
|
||||
# Verify result
|
||||
assert result.name == "My Project"
|
||||
assert result.path == "my-project"
|
||||
assert result.message == "Created successfully"
|
||||
|
||||
# Verify permalink was generated
|
||||
mock_permalink.assert_called_once_with("My Project")
|
||||
|
||||
# Verify API request
|
||||
call_args = mock_request.call_args
|
||||
assert call_args[1]["method"] == "POST"
|
||||
assert call_args[1]["url"] == "https://example.com/proxy/projects/projects"
|
||||
assert call_args[1]["headers"]["Content-Type"] == "application/json"
|
||||
|
||||
json_data = call_args[1]["json_data"]
|
||||
assert json_data["name"] == "My Project"
|
||||
assert json_data["path"] == "my-project"
|
||||
assert json_data["set_default"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generates_permalink_from_name(self):
|
||||
"""Test that permalink is generated from project name."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
|
||||
) as mock_permalink:
|
||||
mock_config.return_value.config.cloud_host = "https://example.com"
|
||||
mock_permalink.return_value = "test-project-123"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"name": "Test Project 123",
|
||||
"path": "test-project-123",
|
||||
"message": "Created",
|
||||
}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
await create_cloud_project("Test Project 123")
|
||||
|
||||
# Verify generate_permalink was called with project name
|
||||
mock_permalink.assert_called_once_with("Test Project 123")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_error_on_api_failure(self):
|
||||
"""Test that CloudUtilsError is raised on API failure."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
|
||||
) as mock_permalink:
|
||||
mock_config.return_value.config.cloud_host = "https://example.com"
|
||||
mock_permalink.return_value = "project"
|
||||
mock_request.side_effect = Exception("API Error")
|
||||
|
||||
with pytest.raises(CloudUtilsError) as exc_info:
|
||||
await create_cloud_project("Test Project")
|
||||
|
||||
assert "Failed to create cloud project 'Test Project'" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strips_trailing_slash_from_host(self):
|
||||
"""Test that trailing slash is stripped from cloud_host."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
|
||||
) as mock_permalink:
|
||||
mock_config.return_value.config.cloud_host = "https://example.com/"
|
||||
mock_permalink.return_value = "project"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"name": "Project",
|
||||
"path": "project",
|
||||
"message": "Created",
|
||||
}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
await create_cloud_project("Project")
|
||||
|
||||
# Verify trailing slash was removed
|
||||
call_args = mock_request.call_args
|
||||
assert call_args[1]["url"] == "https://example.com/proxy/projects/projects"
|
||||
|
||||
|
||||
class TestSyncProject:
|
||||
"""Tests for sync_project()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_syncs_project_successfully(self):
|
||||
"""Test successful project sync."""
|
||||
# Patch at the point where it's imported (inside the function)
|
||||
with patch("basic_memory.cli.commands.command_utils.run_sync", new_callable=AsyncMock) as mock_sync:
|
||||
await sync_project("test-project")
|
||||
|
||||
# Verify run_sync was called with project name
|
||||
mock_sync.assert_called_once_with(project="test-project")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_error_on_sync_failure(self):
|
||||
"""Test that CloudUtilsError is raised on sync failure."""
|
||||
# Patch at the point where it's imported (inside the function)
|
||||
with patch("basic_memory.cli.commands.command_utils.run_sync", new_callable=AsyncMock) as mock_sync:
|
||||
mock_sync.side_effect = Exception("Sync failed")
|
||||
|
||||
with pytest.raises(CloudUtilsError) as exc_info:
|
||||
await sync_project("test-project")
|
||||
|
||||
assert "Failed to sync project 'test-project'" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestProjectExists:
|
||||
"""Tests for project_exists()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_true_when_project_exists(self):
|
||||
"""Test that True is returned when project exists."""
|
||||
from basic_memory.schemas.cloud import CloudProject, CloudProjectList
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
|
||||
) as mock_fetch:
|
||||
# Create actual CloudProject objects
|
||||
projects = CloudProjectList(
|
||||
projects=[
|
||||
CloudProject(name="project-1", path="/app/data/project-1"),
|
||||
CloudProject(name="test-project", path="/app/data/test-project"),
|
||||
CloudProject(name="project-2", path="/app/data/project-2"),
|
||||
]
|
||||
)
|
||||
mock_fetch.return_value = projects
|
||||
|
||||
result = await project_exists("test-project")
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_project_not_found(self):
|
||||
"""Test that False is returned when project doesn't exist."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
|
||||
) as mock_fetch:
|
||||
# Mock project list without matching project
|
||||
mock_projects = Mock()
|
||||
mock_projects.projects = [
|
||||
Mock(name="project-1"),
|
||||
Mock(name="project-2"),
|
||||
]
|
||||
mock_fetch.return_value = mock_projects
|
||||
|
||||
result = await project_exists("nonexistent-project")
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_on_api_error(self):
|
||||
"""Test that False is returned on API error."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
|
||||
) as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("API Error")
|
||||
|
||||
result = await project_exists("test-project")
|
||||
|
||||
# Should return False instead of raising exception
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_empty_project_list(self):
|
||||
"""Test handling of empty project list."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
|
||||
) as mock_fetch:
|
||||
mock_projects = Mock()
|
||||
mock_projects.projects = []
|
||||
mock_fetch.return_value = mock_projects
|
||||
|
||||
result = await project_exists("any-project")
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_sensitive_matching(self):
|
||||
"""Test that project name matching is case-sensitive."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
|
||||
) as mock_fetch:
|
||||
mock_projects = Mock()
|
||||
mock_projects.projects = [Mock(name="Test-Project")]
|
||||
mock_fetch.return_value = mock_projects
|
||||
|
||||
# Different case should not match
|
||||
result = await project_exists("test-project")
|
||||
|
||||
assert result is False
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Tests for upload module."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from basic_memory.cli.commands.cloud.upload import _get_files_to_upload, upload_path
|
||||
|
||||
|
||||
class TestGetFilesToUpload:
|
||||
"""Tests for _get_files_to_upload()."""
|
||||
|
||||
def test_collects_files_from_directory(self, tmp_path):
|
||||
"""Test collecting files from a directory."""
|
||||
# Create test directory structure
|
||||
(tmp_path / "file1.txt").write_text("content1")
|
||||
(tmp_path / "file2.md").write_text("content2")
|
||||
(tmp_path / "subdir").mkdir()
|
||||
(tmp_path / "subdir" / "file3.py").write_text("content3")
|
||||
|
||||
# Call with real ignore utils (no mocking)
|
||||
result = _get_files_to_upload(tmp_path)
|
||||
|
||||
# Should find all 3 files
|
||||
assert len(result) == 3
|
||||
|
||||
# Extract just the relative paths for easier assertion
|
||||
relative_paths = [rel_path for _, rel_path in result]
|
||||
assert "file1.txt" in relative_paths
|
||||
assert "file2.md" in relative_paths
|
||||
assert "subdir/file3.py" in relative_paths
|
||||
|
||||
def test_respects_gitignore_patterns(self, tmp_path):
|
||||
"""Test that gitignore patterns are respected."""
|
||||
# Create test files
|
||||
(tmp_path / "keep.txt").write_text("keep")
|
||||
(tmp_path / "ignore.pyc").write_text("ignore")
|
||||
|
||||
# Create .gitignore file
|
||||
gitignore_file = tmp_path / ".gitignore"
|
||||
gitignore_file.write_text("*.pyc\n")
|
||||
|
||||
result = _get_files_to_upload(tmp_path)
|
||||
|
||||
# Should only find keep.txt (not .pyc or .gitignore itself)
|
||||
relative_paths = [rel_path for _, rel_path in result]
|
||||
assert "keep.txt" in relative_paths
|
||||
assert "ignore.pyc" not in relative_paths
|
||||
|
||||
def test_handles_empty_directory(self, tmp_path):
|
||||
"""Test handling of empty directory."""
|
||||
empty_dir = tmp_path / "empty"
|
||||
empty_dir.mkdir()
|
||||
|
||||
result = _get_files_to_upload(empty_dir)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_converts_windows_paths_to_forward_slashes(self, tmp_path):
|
||||
"""Test that Windows backslashes are converted to forward slashes."""
|
||||
# Create nested structure
|
||||
(tmp_path / "dir1").mkdir()
|
||||
(tmp_path / "dir1" / "dir2").mkdir()
|
||||
(tmp_path / "dir1" / "dir2" / "file.txt").write_text("content")
|
||||
|
||||
result = _get_files_to_upload(tmp_path)
|
||||
|
||||
# Remote path should use forward slashes
|
||||
_, remote_path = result[0]
|
||||
assert "\\" not in remote_path # No backslashes
|
||||
assert "dir1/dir2/file.txt" == remote_path
|
||||
|
||||
|
||||
class TestUploadPath:
|
||||
"""Tests for upload_path()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uploads_single_file(self, tmp_path):
|
||||
"""Test uploading a single file."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("test content")
|
||||
|
||||
# Mock the client and HTTP response
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
# Setup mocks
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
# Mock file reading
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.return_value = b"test content"
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
result = await upload_path(test_file, "test-project")
|
||||
|
||||
# Verify success
|
||||
assert result is True
|
||||
|
||||
# Verify PUT was called with correct path
|
||||
mock_put.assert_called_once()
|
||||
call_args = mock_put.call_args
|
||||
assert call_args[0][0] == mock_client
|
||||
assert call_args[0][1] == "/webdav/test-project/test.txt"
|
||||
assert call_args[1]["content"] == b"test content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uploads_directory(self, tmp_path):
|
||||
"""Test uploading a directory with multiple files."""
|
||||
# Create test files
|
||||
(tmp_path / "file1.txt").write_text("content1")
|
||||
(tmp_path / "file2.txt").write_text("content2")
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch("basic_memory.cli.commands.cloud.upload._get_files_to_upload") as mock_get_files:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
# Setup mocks
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
# Mock file listing
|
||||
mock_get_files.return_value = [
|
||||
(tmp_path / "file1.txt", "file1.txt"),
|
||||
(tmp_path / "file2.txt", "file2.txt"),
|
||||
]
|
||||
|
||||
# Mock file reading
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.side_effect = [b"content1", b"content2"]
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
result = await upload_path(tmp_path, "test-project")
|
||||
|
||||
# Verify success
|
||||
assert result is True
|
||||
|
||||
# Verify PUT was called twice
|
||||
assert mock_put.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_nonexistent_path(self, tmp_path):
|
||||
"""Test handling of nonexistent path."""
|
||||
nonexistent = tmp_path / "does-not-exist"
|
||||
|
||||
result = await upload_path(nonexistent, "test-project")
|
||||
|
||||
# Should return False
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_http_error(self, tmp_path):
|
||||
"""Test handling of HTTP errors during upload."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 403
|
||||
mock_response.text = "Forbidden"
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Forbidden", request=Mock(), response=mock_response
|
||||
)
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
# Setup mocks
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
# Mock file reading
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.return_value = b"test content"
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
result = await upload_path(test_file, "test-project")
|
||||
|
||||
# Should return False on error
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_empty_directory(self, tmp_path):
|
||||
"""Test uploading an empty directory."""
|
||||
empty_dir = tmp_path / "empty"
|
||||
empty_dir.mkdir()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload._get_files_to_upload") as mock_get_files:
|
||||
mock_get_files.return_value = []
|
||||
|
||||
result = await upload_path(empty_dir, "test-project")
|
||||
|
||||
# Should return True (no-op success)
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_formats_file_size_bytes(self, tmp_path, capsys):
|
||||
"""Test file size formatting for small files (bytes)."""
|
||||
test_file = tmp_path / "small.txt"
|
||||
test_file.write_text("hi") # 2 bytes
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.return_value = b"hi"
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
await upload_path(test_file, "test-project")
|
||||
|
||||
# Check output contains "bytes"
|
||||
captured = capsys.readouterr()
|
||||
assert "bytes" in captured.out
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_formats_file_size_kilobytes(self, tmp_path, capsys):
|
||||
"""Test file size formatting for medium files (KB)."""
|
||||
test_file = tmp_path / "medium.txt"
|
||||
# Create file with 2KB of content
|
||||
test_file.write_text("x" * 2048)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.return_value = b"x" * 2048
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
await upload_path(test_file, "test-project")
|
||||
|
||||
# Check output contains "KB"
|
||||
captured = capsys.readouterr()
|
||||
assert "KB" in captured.out
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_formats_file_size_megabytes(self, tmp_path, capsys):
|
||||
"""Test file size formatting for large files (MB)."""
|
||||
test_file = tmp_path / "large.txt"
|
||||
# Create file with 2MB of content
|
||||
test_file.write_text("x" * (2 * 1024 * 1024))
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.return_value = b"x" * (2 * 1024 * 1024)
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
await upload_path(test_file, "test-project")
|
||||
|
||||
# Check output contains "MB"
|
||||
captured = capsys.readouterr()
|
||||
assert "MB" in captured.out
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builds_correct_webdav_path(self, tmp_path):
|
||||
"""Test that WebDAV path is correctly constructed."""
|
||||
# Create nested structure
|
||||
(tmp_path / "subdir").mkdir()
|
||||
test_file = tmp_path / "subdir" / "file.txt"
|
||||
test_file.write_text("content")
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch("basic_memory.cli.commands.cloud.upload._get_files_to_upload") as mock_get_files:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
# Mock file listing with relative path
|
||||
mock_get_files.return_value = [(test_file, "subdir/file.txt")]
|
||||
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.return_value = b"content"
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
await upload_path(tmp_path, "my-project")
|
||||
|
||||
# Verify WebDAV path format: /webdav/{project_name}/{relative_path}
|
||||
mock_put.assert_called_once()
|
||||
call_args = mock_put.call_args
|
||||
assert call_args[0][1] == "/webdav/my-project/subdir/file.txt"
|
||||
Reference in New Issue
Block a user