feat: add create_folder, rename, copy, move, delete, upload tools
All path/name params are json.dumps-wrapped per confirmed DSM behaviour. copy and move use async polling via a shared _poll_task helper (exponential backoff 200ms→2s, 60s timeout). delete requires confirmed=True; without it only a preview is returned and no DSM call is made. upload decodes base64 and enforces a 50 MB cap. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -709,3 +709,369 @@ async def test_download_exactly_10mb(config: AppConfig) -> None:
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert parsed["size"] == len(content)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# create_folder
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_success(config: AppConfig) -> None:
|
||||
"""create_folder returns the path of the created folder."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(
|
||||
return_value={"folders": [{"isdir": True, "name": "new-app", "path": "/docker/new-app"}]}
|
||||
)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["create_folder"](path="/docker", name="new-app")
|
||||
|
||||
assert result == "Created: /docker/new-app"
|
||||
call_params = client.request.call_args[1]["params"]
|
||||
assert call_params["folder_path"] == json.dumps("/docker")
|
||||
assert call_params["name"] == json.dumps("new-app")
|
||||
assert call_params["force_parent"] == "false"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_dsm_error(config: AppConfig) -> None:
|
||||
"""create_folder returns Error: on SynologyError."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(side_effect=SynologyError("No write permission", code=1801))
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["create_folder"](path="/docker", name="new-app")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "permission" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_create_parents(config: AppConfig) -> None:
|
||||
"""create_folder passes force_parent=true when create_parents=True."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(return_value={"folders": []})
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
await tools["create_folder"](path="/docker/deep/path", name="new-app", create_parents=True)
|
||||
|
||||
call_params = client.request.call_args[1]["params"]
|
||||
assert call_params["force_parent"] == "true"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# rename
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_success(config: AppConfig) -> None:
|
||||
"""rename returns the new path of the renamed item."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(
|
||||
return_value={"files": [{"isdir": False, "name": "new.yaml", "path": "/docker/new.yaml"}]}
|
||||
)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["rename"](path="/docker/old.yaml", new_name="new.yaml")
|
||||
|
||||
assert result == "Renamed to: /docker/new.yaml"
|
||||
call_params = client.request.call_args[1]["params"]
|
||||
assert call_params["path"] == json.dumps("/docker/old.yaml")
|
||||
assert call_params["name"] == json.dumps("new.yaml")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_dsm_error(config: AppConfig) -> None:
|
||||
"""rename returns Error: on SynologyError."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(side_effect=SynologyError("File or folder not found", code=1800))
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["rename"](path="/docker/missing.yaml", new_name="new.yaml")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "not found" in result.lower()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# copy
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_success_with_polling(config: AppConfig) -> None:
|
||||
"""copy polls two rounds then returns the destination path."""
|
||||
client = MagicMock()
|
||||
poll_calls = 0
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
nonlocal poll_calls
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_copy1"}
|
||||
if method == "status":
|
||||
poll_calls += 1
|
||||
finished = poll_calls >= 2
|
||||
return {
|
||||
"finished": finished,
|
||||
"progress": 1.0 if finished else 0.5,
|
||||
"dest_folder_path": "/backup/docker",
|
||||
}
|
||||
return {}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["copy"](src="/docker/app/compose.yaml", dst="/backup/docker")
|
||||
|
||||
assert result == "Copied to: /backup/docker/compose.yaml"
|
||||
assert poll_calls == 2
|
||||
# Verify start used correct params
|
||||
start_call = client.request.call_args_list[0]
|
||||
assert start_call[0][0] == "SYNO.FileStation.CopyMove"
|
||||
assert start_call[0][1] == "start"
|
||||
start_params = start_call[1]["params"]
|
||||
assert start_params["path"] == json.dumps("/docker/app/compose.yaml")
|
||||
assert start_params["dest_folder_path"] == json.dumps("/backup/docker")
|
||||
assert start_params["remove_src"] == "false"
|
||||
assert start_params["overwrite"] == "false"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_timeout(config: AppConfig) -> None:
|
||||
"""copy returns an error message after polling times out."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_copy_timeout"}
|
||||
return {"finished": False, "progress": 0.1}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["copy"](src="/docker/big.tar", dst="/backup")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "timed out" in result.lower() or "60 seconds" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_dsm_error_on_start(config: AppConfig) -> None:
|
||||
"""copy returns Error: when the start call fails."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(side_effect=SynologyError("No write permission", code=1801))
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["copy"](src="/docker/app.yaml", dst="/backup")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# move
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_success(config: AppConfig) -> None:
|
||||
"""move returns destination path and passes remove_src=true."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_move1"}
|
||||
return {"finished": True, "dest_folder_path": "/archive/docker"}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["move"](src="/docker/old.yaml", dst="/archive/docker")
|
||||
|
||||
assert result == "Moved to: /archive/docker/old.yaml"
|
||||
start_params = client.request.call_args_list[0][1]["params"]
|
||||
assert start_params["remove_src"] == "true"
|
||||
assert start_params["path"] == json.dumps("/docker/old.yaml")
|
||||
assert start_params["dest_folder_path"] == json.dumps("/archive/docker")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_dsm_error(config: AppConfig) -> None:
|
||||
"""move returns Error: when the start call fails."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(side_effect=SynologyError("File or folder not found", code=1800))
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["move"](src="/docker/missing.yaml", dst="/backup")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "not found" in result.lower()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# delete
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_preview_no_dsm_call(config: AppConfig) -> None:
|
||||
"""delete with confirmed=False returns a preview and makes no DSM requests."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock()
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["delete"](path="/docker/app", confirmed=False)
|
||||
|
||||
client.request.assert_not_called()
|
||||
assert "/docker/app" in result
|
||||
assert "confirmed=True" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_confirmed_with_polling(config: AppConfig) -> None:
|
||||
"""delete with confirmed=True polls until finished and returns success."""
|
||||
client = MagicMock()
|
||||
poll_calls = 0
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
nonlocal poll_calls
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_del1"}
|
||||
if method == "status":
|
||||
poll_calls += 1
|
||||
return {"finished": poll_calls >= 2, "processed_num": poll_calls}
|
||||
return {}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["delete"](path="/docker/old-app", confirmed=True)
|
||||
|
||||
assert result == "Deleted: /docker/old-app"
|
||||
start_params = client.request.call_args_list[0][1]["params"]
|
||||
assert start_params["path"] == json.dumps("/docker/old-app")
|
||||
assert start_params["recursive"] == "true"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_dsm_error_on_start(config: AppConfig) -> None:
|
||||
"""delete returns Error: when the start call fails."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(
|
||||
side_effect=SynologyError("Permission denied — check DSM user permissions", code=105)
|
||||
)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["delete"](path="/docker/app", confirmed=True)
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "Permission denied" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# upload
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_success(config: AppConfig) -> None:
|
||||
"""upload decodes base64, calls upload_bytes, and returns the full path."""
|
||||
import base64 as _b64
|
||||
|
||||
raw = b"version: '3'\nservices:\n app:\n image: nginx\n"
|
||||
encoded = _b64.b64encode(raw).decode()
|
||||
|
||||
client = MagicMock()
|
||||
client.upload_bytes = AsyncMock(return_value={})
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["upload"](
|
||||
path="/docker/app", filename="compose.yaml", content_base64=encoded
|
||||
)
|
||||
|
||||
assert result == "Uploaded: /docker/app/compose.yaml"
|
||||
client.upload_bytes.assert_called_once()
|
||||
call_kwargs = client.upload_bytes.call_args[1]
|
||||
assert call_kwargs["dest_folder"] == "/docker/app"
|
||||
assert call_kwargs["filename"] == "compose.yaml"
|
||||
assert call_kwargs["content"] == raw
|
||||
assert call_kwargs["overwrite"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_too_large(config: AppConfig) -> None:
|
||||
"""upload returns Error: when decoded content exceeds 50 MB."""
|
||||
import base64 as _b64
|
||||
|
||||
large = _b64.b64encode(b"x" * (50 * 1024 * 1024 + 1)).decode()
|
||||
|
||||
client = MagicMock()
|
||||
client.upload_bytes = AsyncMock(return_value={})
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["upload"](path="/data", filename="big.bin", content_base64=large)
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "50 MB" in result or "exceeds" in result
|
||||
client.upload_bytes.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_create_parents(config: AppConfig) -> None:
|
||||
"""upload passes create_parents=True to upload_bytes."""
|
||||
import base64 as _b64
|
||||
|
||||
client = MagicMock()
|
||||
client.upload_bytes = AsyncMock(return_value={})
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
await tools["upload"](
|
||||
path="/docker/deep/path",
|
||||
filename="file.txt",
|
||||
content_base64=_b64.b64encode(b"hello").decode(),
|
||||
create_parents=True,
|
||||
)
|
||||
|
||||
call_kwargs = client.upload_bytes.call_args[1]
|
||||
assert call_kwargs["create_parents"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_invalid_base64(config: AppConfig) -> None:
|
||||
"""upload returns Error: when content_base64 is not valid base64."""
|
||||
client = MagicMock()
|
||||
client.upload_bytes = AsyncMock(return_value={})
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["upload"](path="/docker", filename="f.txt", content_base64="not-base64!!!")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "base64" in result.lower()
|
||||
client.upload_bytes.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_dsm_error(config: AppConfig) -> None:
|
||||
"""upload returns Error: on SynologyError from upload_bytes."""
|
||||
import base64 as _b64
|
||||
|
||||
client = MagicMock()
|
||||
client.upload_bytes = AsyncMock(side_effect=SynologyError("No write permission", code=1801))
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["upload"](
|
||||
path="/docker",
|
||||
filename="compose.yaml",
|
||||
content_base64=_b64.b64encode(b"data").decode(),
|
||||
)
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "permission" in result.lower()
|
||||
|
||||
Reference in New Issue
Block a user