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:
2026-04-14 10:28:32 +02:00
parent 544cfb8b06
commit 80ac894165
3 changed files with 677 additions and 1 deletions
+366
View File
@@ -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()