feat: add search and download tools
Implements SYNO.FileStation.Search with async polling (exponential backoff 200ms→2s, 60s timeout) and SYNO.FileStation.Download with base64 output and a 10 MB size cap. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -414,3 +415,262 @@ async def test_get_info_uses_getinfo_method(config: AppConfig) -> None:
|
||||
call_args = client.request.call_args
|
||||
assert call_args[0][0] == "SYNO.FileStation.List"
|
||||
assert call_args[0][1] == "getinfo"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# search
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_SEARCH_FILE = {
|
||||
"path": "/docker/app/compose.yaml",
|
||||
"name": "compose.yaml",
|
||||
"isdir": False,
|
||||
"additional": {"size": 1024, "time": {"mtime": 1700000000}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_success(config: AppConfig) -> None:
|
||||
"""search returns a formatted table after polling two rounds until finished=True."""
|
||||
client = MagicMock()
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def _request(api, method, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if method == "start":
|
||||
return {"taskid": "abc123", "has_not_index_share": False}
|
||||
if method == "list":
|
||||
# First poll: not finished yet; second poll: finished
|
||||
finished = call_count >= 4 # start=1, list1=2, list2=3 → finished on call 3
|
||||
return {"files": [_SEARCH_FILE], "finished": finished, "total": 1}
|
||||
if method == "clean":
|
||||
return {}
|
||||
return {}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["search"](path="/docker", pattern="*.yaml")
|
||||
|
||||
assert "/docker/app/compose.yaml" in result
|
||||
assert "file" in result
|
||||
assert "1 match(es) found" in result
|
||||
# Verify start was called with correct params
|
||||
start_call = client.request.call_args_list[0]
|
||||
assert start_call[0][0] == "SYNO.FileStation.Search"
|
||||
assert start_call[0][1] == "start"
|
||||
assert start_call[1]["params"]["folder_path"] == "/docker"
|
||||
assert start_call[1]["params"]["pattern"] == "*.yaml"
|
||||
assert start_call[1]["params"]["recursive"] == "true"
|
||||
# Verify clean was called last
|
||||
last_call = client.request.call_args_list[-1]
|
||||
assert last_call[0][1] == "clean"
|
||||
assert last_call[1]["params"]["taskid"] == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_polls_until_finished(config: AppConfig) -> None:
|
||||
"""search keeps polling when finished=False and stops once finished=True."""
|
||||
client = MagicMock()
|
||||
poll_calls = 0
|
||||
|
||||
async def _request(api, method, **kwargs):
|
||||
nonlocal poll_calls
|
||||
if method == "start":
|
||||
return {"taskid": "t1"}
|
||||
if method == "list":
|
||||
poll_calls += 1
|
||||
return {
|
||||
"files": [_SEARCH_FILE],
|
||||
"finished": poll_calls >= 3, # finish on third list call
|
||||
"total": 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["search"](path="/docker", pattern="*.yaml")
|
||||
|
||||
assert poll_calls == 3
|
||||
assert "1 match(es) found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_no_results(config: AppConfig) -> None:
|
||||
"""search returns a friendly message when no files are found."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "t2"}
|
||||
if method == "list":
|
||||
return {"files": [], "finished": True, "total": 0}
|
||||
return {}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["search"](path="/docker", pattern="*.txt")
|
||||
|
||||
assert "No files" in result or "not found" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_start_dsm_error(config: AppConfig) -> None:
|
||||
"""search returns Error: immediately 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["search"](path="/docker", pattern="*.yaml")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "Permission denied" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_list_dsm_error(config: AppConfig) -> None:
|
||||
"""search returns Error: and cleans up when a list poll fails."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "t3"}
|
||||
if method == "list":
|
||||
raise SynologyError("Unknown error", code=100)
|
||||
return {}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["search"](path="/docker", pattern="*.yaml")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
# clean should have been attempted
|
||||
clean_calls = [c for c in client.request.call_args_list if c[0][1] == "clean"]
|
||||
assert len(clean_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_recursive_false(config: AppConfig) -> None:
|
||||
"""search passes recursive=false when recursive=False."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "t4"}
|
||||
if method == "list":
|
||||
return {"files": [], "finished": True, "total": 0}
|
||||
return {}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
await tools["search"](path="/docker", pattern="*.yaml", recursive=False)
|
||||
|
||||
start_call = client.request.call_args_list[0]
|
||||
assert start_call[1]["params"]["recursive"] == "false"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_additional_format(config: AppConfig) -> None:
|
||||
"""search uses json.dumps(["size","time"]) for additional parameter."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "t5"}
|
||||
if method == "list":
|
||||
return {"files": [], "finished": True, "total": 0}
|
||||
return {}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
await tools["search"](path="/docker", pattern="*.yaml")
|
||||
|
||||
list_call = client.request.call_args_list[1]
|
||||
assert list_call[1]["params"]["additional"] == json.dumps(["size", "time"])
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# download
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_success(config: AppConfig) -> None:
|
||||
"""download returns JSON with filename, size, and valid base64 content."""
|
||||
content = b"hello, world"
|
||||
client = MagicMock()
|
||||
client.download_bytes = AsyncMock(return_value=("compose.yaml", content))
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["download"](path="/docker/app/compose.yaml")
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert parsed["filename"] == "compose.yaml"
|
||||
assert parsed["size"] == len(content)
|
||||
assert base64.b64decode(parsed["content_base64"]) == content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_size_limit(config: AppConfig) -> None:
|
||||
"""download returns Error: when file exceeds 10 MB."""
|
||||
large_content = b"x" * (10 * 1024 * 1024 + 1)
|
||||
client = MagicMock()
|
||||
client.download_bytes = AsyncMock(return_value=("bigfile.bin", large_content))
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["download"](path="/data/bigfile.bin")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "10 MB" in result or "exceeds" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_dsm_error(config: AppConfig) -> None:
|
||||
"""download returns Error: on SynologyError."""
|
||||
client = MagicMock()
|
||||
client.download_bytes = AsyncMock(
|
||||
side_effect=SynologyError("File or folder not found", code=1800)
|
||||
)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["download"](path="/data/missing.txt")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "not found" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_exactly_10mb(config: AppConfig) -> None:
|
||||
"""download accepts files exactly at the 10 MB boundary."""
|
||||
content = b"x" * (10 * 1024 * 1024)
|
||||
client = MagicMock()
|
||||
client.download_bytes = AsyncMock(return_value=("edge.bin", content))
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["download"](path="/data/edge.bin")
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert parsed["size"] == len(content)
|
||||
|
||||
Reference in New Issue
Block a user