feat: add compress and extract tools

Implements SYNO.FileStation.Compress (v3) and SYNO.FileStation.Extract (v2)
with async polling identical to copy/move. Includes input validation for
compress (level, mode, format, empty paths) and 11 new unit tests.
Bumps version to 0.2.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 11:26:08 +02:00
parent dbab842738
commit 473c771c20
4 changed files with 432 additions and 2 deletions
+299
View File
@@ -1152,6 +1152,305 @@ async def test_check_exist_multi_path(config: AppConfig) -> None:
assert "/ghost" in requested_paths
# ──────────────────────────────────────────────────────────────────────────
# compress
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_compress_success(config: AppConfig) -> None:
"""compress polls until finished and returns the archive path."""
client = MagicMock()
async def _request(api, method, version=None, params=None, **kwargs):
if method == "start":
return {"taskid": "FileStation_compress1"}
if method == "status":
return {"finished": True}
return {}
client.request = AsyncMock(side_effect=_request)
tools = _make_mcp_and_tools(config, client)
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await tools["compress"](
paths=["/data/report.pdf", "/data/photos"],
dest_file_path="/backup/archive.zip",
)
assert result == "Compressed to: /backup/archive.zip"
# Verify DSM call parameters
start_call = client.request.call_args_list[0]
assert start_call[0][0] == "SYNO.FileStation.Compress"
assert start_call[0][1] == "start"
assert start_call[1]["version"] == 3
p = start_call[1]["params"]
assert json.loads(p["path"]) == ["/data/report.pdf", "/data/photos"]
assert json.loads(p["dest_file_path"]) == "/backup/archive.zip"
assert p["level"] == "moderate"
assert p["mode"] == "add"
assert p["format"] == "zip"
assert p["compress_password"] == ""
@pytest.mark.asyncio
async def test_compress_polling_multiple_rounds(config: AppConfig) -> None:
"""compress returns success after multiple polling rounds."""
client = MagicMock()
poll_calls = 0
async def _request(api, method, version=None, params=None, **kwargs):
nonlocal poll_calls
if method == "start":
return {"taskid": "FileStation_compress2"}
if method == "status":
poll_calls += 1
return {"finished": poll_calls >= 3}
return {}
client.request = AsyncMock(side_effect=_request)
tools = _make_mcp_and_tools(config, client)
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await tools["compress"](
paths=["/data/big-folder"],
dest_file_path="/backup/big.7z",
format="7z",
level="maximum",
)
assert result == "Compressed to: /backup/big.7z"
assert poll_calls == 3
@pytest.mark.asyncio
async def test_compress_dsm_error_on_start(config: AppConfig) -> None:
"""compress 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["compress"](
paths=["/data/file.txt"],
dest_file_path="/backup/out.zip",
)
assert result.startswith("Error:")
assert "permission" in result.lower()
@pytest.mark.asyncio
async def test_compress_invalid_level(config: AppConfig) -> None:
"""compress rejects unknown level values before making any DSM call."""
client = MagicMock()
client.request = AsyncMock()
tools = _make_mcp_and_tools(config, client)
result = await tools["compress"](
paths=["/data/file.txt"],
dest_file_path="/backup/out.zip",
level="ultra",
)
assert result.startswith("Error:")
assert "level" in result
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_compress_invalid_format(config: AppConfig) -> None:
"""compress rejects unknown format values before making any DSM call."""
client = MagicMock()
client.request = AsyncMock()
tools = _make_mcp_and_tools(config, client)
result = await tools["compress"](
paths=["/data/file.txt"],
dest_file_path="/backup/out.zip",
format="tar.gz",
)
assert result.startswith("Error:")
assert "format" in result
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_compress_empty_paths(config: AppConfig) -> None:
"""compress rejects an empty paths list before making any DSM call."""
client = MagicMock()
client.request = AsyncMock()
tools = _make_mcp_and_tools(config, client)
result = await tools["compress"](paths=[], dest_file_path="/backup/out.zip")
assert result.startswith("Error:")
assert "paths" in result.lower() or "empty" in result.lower()
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_compress_timeout(config: AppConfig) -> None:
"""compress returns an error after polling times out."""
client = MagicMock()
async def _request(api, method, version=None, params=None, **kwargs):
if method == "start":
return {"taskid": "FileStation_compress_timeout"}
return {"finished": False}
client.request = AsyncMock(side_effect=_request)
tools = _make_mcp_and_tools(config, client)
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await tools["compress"](
paths=["/data/huge"],
dest_file_path="/backup/huge.zip",
)
assert result.startswith("Error:")
assert "timed out" in result.lower() or "60 seconds" in result
# ──────────────────────────────────────────────────────────────────────────
# extract
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_extract_success(config: AppConfig) -> None:
"""extract polls until finished and returns the dest_folder_path from status."""
client = MagicMock()
async def _request(api, method, version=None, params=None, **kwargs):
if method == "start":
return {"taskid": "FileStation_extract1"}
if method == "status":
return {
"finished": True,
"dest_folder_path": "/data/extracted",
"path": "/backup/archive.zip",
"progress": 1,
}
return {}
client.request = AsyncMock(side_effect=_request)
tools = _make_mcp_and_tools(config, client)
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await tools["extract"](
file_path="/backup/archive.zip",
dest_folder_path="/data/extracted",
)
assert result == "Extracted to: /data/extracted"
# Verify DSM call parameters
start_call = client.request.call_args_list[0]
assert start_call[0][0] == "SYNO.FileStation.Extract"
assert start_call[0][1] == "start"
assert start_call[1]["version"] == 2
p = start_call[1]["params"]
assert json.loads(p["file_path"]) == "/backup/archive.zip"
assert json.loads(p["dest_folder_path"]) == "/data/extracted"
assert p["overwrite"] == "false"
assert p["keep_dir"] == "true"
assert p["create_subfolder"] == "false"
assert p["codepage"] == "enu"
assert p["password"] == ""
@pytest.mark.asyncio
async def test_extract_overwrite_and_subfolder(config: AppConfig) -> None:
"""extract passes overwrite=true and create_subfolder=true when requested."""
client = MagicMock()
async def _request(api, method, version=None, params=None, **kwargs):
if method == "start":
return {"taskid": "FileStation_extract2"}
return {"finished": True, "dest_folder_path": "/data/out"}
client.request = AsyncMock(side_effect=_request)
tools = _make_mcp_and_tools(config, client)
with patch("asyncio.sleep", new_callable=AsyncMock):
await tools["extract"](
file_path="/backup/archive.zip",
dest_folder_path="/data/out",
overwrite=True,
create_subfolder=True,
)
p = client.request.call_args_list[0][1]["params"]
assert p["overwrite"] == "true"
assert p["create_subfolder"] == "true"
@pytest.mark.asyncio
async def test_extract_dest_folder_from_status(config: AppConfig) -> None:
"""extract uses dest_folder_path from status response when available."""
client = MagicMock()
async def _request(api, method, version=None, params=None, **kwargs):
if method == "start":
return {"taskid": "FileStation_extract3"}
return {"finished": True, "dest_folder_path": "/data/real-dest"}
client.request = AsyncMock(side_effect=_request)
tools = _make_mcp_and_tools(config, client)
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await tools["extract"](
file_path="/backup/archive.zip",
dest_folder_path="/data/requested",
)
# Should report what DSM confirmed, not what we requested
assert result == "Extracted to: /data/real-dest"
@pytest.mark.asyncio
async def test_extract_dsm_error_on_start(config: AppConfig) -> None:
"""extract returns Error: when the start call fails (e.g. bad path)."""
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["extract"](
file_path="/backup/missing.zip",
dest_folder_path="/data/out",
)
assert result.startswith("Error:")
assert "not found" in result.lower()
@pytest.mark.asyncio
async def test_extract_timeout(config: AppConfig) -> None:
"""extract returns an error after polling times out."""
client = MagicMock()
async def _request(api, method, version=None, params=None, **kwargs):
if method == "start":
return {"taskid": "FileStation_extract_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["extract"](
file_path="/backup/huge.zip",
dest_folder_path="/data/out",
)
assert result.startswith("Error:")
assert "timed out" in result.lower() or "60 seconds" in result
@pytest.mark.asyncio
async def test_check_exist_empty_path(config: AppConfig) -> None:
"""check_exist returns Error: when no path is given."""