feat: add background_tasks + list_snapshots tools (v0.3.4)
- background_tasks: SYNO.FileStation.BackgroundTask::list (v3) — paginated table of active/recent copy/move/delete/extract/compress tasks - list_snapshots: SYNO.FileStation.Snapshot::list (v2) — Btrfs snapshots per share; maps error 400 to a clear Btrfs-required message - 20 new tests (107 total) - SPEC.md and CLAUDE.md updated (26 tools) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1766,3 +1766,217 @@ async def test_get_md5_missing_hash_in_response(config: AppConfig) -> None:
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "md5" in result.lower() or "hash" in result.lower()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# background_tasks
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_tasks_empty(config: AppConfig) -> None:
|
||||
"""background_tasks returns a 'no tasks' message when the list is empty."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(return_value={"offset": 0, "tasks": [], "total": 0})
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
result = await tools["background_tasks"]()
|
||||
|
||||
assert "No background tasks" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_tasks_with_tasks(config: AppConfig) -> None:
|
||||
"""background_tasks returns a formatted table when tasks are present."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(
|
||||
return_value={
|
||||
"offset": 0,
|
||||
"total": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"taskid": "FileStation_CopyMove_1",
|
||||
"type": "CopyMove",
|
||||
"status": "running",
|
||||
"path": "/docker/dest",
|
||||
"processed_num_file": 3,
|
||||
"total_num_file": 10,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
result = await tools["background_tasks"]()
|
||||
|
||||
assert "FileStation_CopyMove_1" in result
|
||||
assert "CopyMove" in result
|
||||
assert "running" in result
|
||||
assert "/docker/dest" in result
|
||||
assert "3/10" in result
|
||||
assert "1 task(s)" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_tasks_pagination_hint(config: AppConfig) -> None:
|
||||
"""background_tasks shows a pagination hint when there are more results."""
|
||||
client = MagicMock()
|
||||
tasks = [
|
||||
{
|
||||
"taskid": f"task_{i}",
|
||||
"type": "Delete",
|
||||
"status": "running",
|
||||
"path": f"/share/item{i}",
|
||||
"total_num_file": 0,
|
||||
}
|
||||
for i in range(5)
|
||||
]
|
||||
client.request = AsyncMock(return_value={"offset": 0, "total": 20, "tasks": tasks})
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
result = await tools["background_tasks"](offset=0, limit=5)
|
||||
|
||||
assert "20 task(s)" in result
|
||||
assert "offset" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_tasks_dsm_error(config: AppConfig) -> None:
|
||||
"""background_tasks returns Error: when DSM raises an exception."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(
|
||||
side_effect=SynologyError("Permission denied — check DSM user permissions", code=105)
|
||||
)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
result = await tools["background_tasks"]()
|
||||
|
||||
assert result.startswith("Error:")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_tasks_dsm_api_call(config: AppConfig) -> None:
|
||||
"""background_tasks calls the correct DSM API with offset and limit."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(return_value={"offset": 0, "tasks": [], "total": 0})
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
await tools["background_tasks"](offset=10, limit=50)
|
||||
|
||||
call = client.request.call_args
|
||||
assert call[0][0] == "SYNO.FileStation.BackgroundTask"
|
||||
assert call[0][1] == "list"
|
||||
assert call[1]["version"] == 3
|
||||
assert call[1]["params"]["offset"] == 10
|
||||
assert call[1]["params"]["limit"] == 50
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# list_snapshots
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_snapshots_btrfs_not_available(config: AppConfig) -> None:
|
||||
"""list_snapshots returns a Btrfs-specific error message on DSM error 400."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(side_effect=SynologyError("Invalid parameter", code=400))
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
result = await tools["list_snapshots"](share_path="/docker")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "Btrfs" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_snapshots_empty(config: AppConfig) -> None:
|
||||
"""list_snapshots returns a 'no snapshots' message when the list is empty."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(return_value={"snapshots": [], "total": 0})
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
result = await tools["list_snapshots"](share_path="/docker")
|
||||
|
||||
assert "No snapshots" in result
|
||||
assert "/docker" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_snapshots_with_data(config: AppConfig) -> None:
|
||||
"""list_snapshots returns a formatted table when snapshots are present."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(
|
||||
return_value={
|
||||
"total": 2,
|
||||
"snapshots": [
|
||||
{
|
||||
"id": "snap_001",
|
||||
"time": 1700000000,
|
||||
"description": "Before upgrade",
|
||||
"lock": True,
|
||||
},
|
||||
{"id": "snap_002", "time": 1700100000, "description": "", "lock": False},
|
||||
],
|
||||
}
|
||||
)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
result = await tools["list_snapshots"](share_path="/docker")
|
||||
|
||||
assert "snap_001" in result
|
||||
assert "snap_002" in result
|
||||
assert "Before upgrade" in result
|
||||
assert "Yes" in result # locked
|
||||
assert "No" in result # not locked
|
||||
assert "2 snapshot(s)" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_snapshots_dsm_error_other(config: AppConfig) -> None:
|
||||
"""list_snapshots surfaces non-400 DSM errors as 'Error: …'."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(
|
||||
side_effect=SynologyError("Permission denied — check DSM user permissions", code=105)
|
||||
)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
result = await tools["list_snapshots"](share_path="/docker")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "Permission" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_snapshots_dsm_api_call(config: AppConfig) -> None:
|
||||
"""list_snapshots calls SYNO.FileStation.Snapshot::list v2 with the correct params."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(return_value={"snapshots": [], "total": 0})
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
await tools["list_snapshots"](share_path="/data", offset=0, limit=50)
|
||||
|
||||
call = client.request.call_args
|
||||
assert call[0][0] == "SYNO.FileStation.Snapshot"
|
||||
assert call[0][1] == "list"
|
||||
assert call[1]["version"] == 2
|
||||
assert call[1]["params"]["folder_path"] == "/data"
|
||||
assert call[1]["params"]["offset"] == 0
|
||||
assert call[1]["params"]["limit"] == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_snapshots_pagination_hint(config: AppConfig) -> None:
|
||||
"""list_snapshots shows a pagination hint when more results are available."""
|
||||
client = MagicMock()
|
||||
snaps = [
|
||||
{"id": f"snap_{i}", "time": 1700000000 + i * 3600, "description": "", "lock": False}
|
||||
for i in range(3)
|
||||
]
|
||||
client.request = AsyncMock(return_value={"snapshots": snaps, "total": 10})
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
result = await tools["list_snapshots"](share_path="/data", offset=0, limit=3)
|
||||
|
||||
assert "10 snapshot(s)" in result
|
||||
assert "offset" in result.lower()
|
||||
|
||||
Reference in New Issue
Block a user