1bccf1e5d2
DSM accepts multiple paths as a JSON array string, not comma-separated. Comma-separated is treated as a single literal path; repeated path[] params return error 400. Confirmed via test_getinfo_multipath.py. - get_info: path param changed from ",".join(paths) to json.dumps(paths) - tests: update multi-path assertion to expect JSON array format Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
417 lines
14 KiB
Python
417 lines
14 KiB
Python
"""Tests for tools/filestation.py: list_shares and list_dir."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
import pytest
|
||
|
||
from mcp_synology_filestation.client import SynologyError
|
||
from mcp_synology_filestation.config import AppConfig, ConnectionConfig
|
||
|
||
|
||
@pytest.fixture()
|
||
def config() -> AppConfig:
|
||
return AppConfig(
|
||
schema_version=1,
|
||
connection=ConnectionConfig(host="nas.example.com"),
|
||
)
|
||
|
||
|
||
def _make_mcp_and_tools(config: AppConfig, client: MagicMock) -> dict:
|
||
"""Register FileStation tools on a mock FastMCP and collect them by name."""
|
||
from mcp_synology_filestation.tools.filestation import register_filestation
|
||
|
||
registered: dict[str, object] = {}
|
||
|
||
mcp = MagicMock()
|
||
|
||
def tool_decorator():
|
||
"""Simulate @mcp.tool() — capture the decorated function."""
|
||
|
||
def decorator(fn):
|
||
registered[fn.__name__] = fn
|
||
return fn
|
||
|
||
return decorator
|
||
|
||
mcp.tool = tool_decorator
|
||
register_filestation(mcp, config, client)
|
||
return registered
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# list_shares
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_shares_success(config: AppConfig) -> None:
|
||
"""list_shares returns a formatted table on success."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock(
|
||
return_value={
|
||
"shares": [
|
||
{
|
||
"name": "data",
|
||
"path": "/data",
|
||
"additional": {
|
||
"real_path": "/volume1/data",
|
||
"volume_status": {
|
||
"totalspace": 2_000_000_000,
|
||
"usedspace": 500_000_000,
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "photos",
|
||
"path": "/photos",
|
||
"additional": {
|
||
"real_path": "/volume1/photos",
|
||
"volume_status": {},
|
||
},
|
||
},
|
||
]
|
||
}
|
||
)
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
result = await tools["list_shares"]()
|
||
|
||
assert "data" in result
|
||
assert "/data" in result # share path, not volume path
|
||
assert "/volume1/data" not in result
|
||
assert "photos" in result
|
||
assert "2 share(s) found" in result
|
||
# Check usage percentage appears
|
||
assert "%" in result
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_shares_empty(config: AppConfig) -> None:
|
||
"""list_shares returns a friendly message when no shares exist."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock(return_value={"shares": []})
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
result = await tools["list_shares"]()
|
||
|
||
assert "No shared folders" in result
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_shares_dsm_error(config: AppConfig) -> None:
|
||
"""list_shares returns an Error: message on SynologyError."""
|
||
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_shares"]()
|
||
|
||
assert result.startswith("Error:")
|
||
assert "Permission denied" in result
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# list_dir
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_dir_success(config: AppConfig) -> None:
|
||
"""list_dir returns a formatted table with name, type, size, and modified columns."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock(
|
||
return_value={
|
||
"total": 3,
|
||
"files": [
|
||
{
|
||
"name": "documents",
|
||
"isdir": True,
|
||
"additional": {"size": 0, "time": {"mtime": 1700000000}},
|
||
},
|
||
{
|
||
"name": "photo.jpg",
|
||
"isdir": False,
|
||
"additional": {"size": 2_048_000, "time": {"mtime": 1710000000}},
|
||
},
|
||
{
|
||
"name": "readme.txt",
|
||
"isdir": False,
|
||
"additional": {"size": 512, "time": {"mtime": 1720000000}},
|
||
},
|
||
],
|
||
}
|
||
)
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
result = await tools["list_dir"](path="/dev")
|
||
|
||
assert "documents" in result
|
||
assert "photo.jpg" in result
|
||
assert "readme.txt" in result
|
||
assert "dir" in result
|
||
assert "file" in result
|
||
# Size column: dirs show "-", files show human-readable size
|
||
assert "2 MB" in result or "1 MB" in result # photo.jpg ~2 MB
|
||
assert "512 B" in result # readme.txt
|
||
# Modified column present
|
||
assert "Modified" in result
|
||
assert "Showing 1–3 of 3 item(s)" in result
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_dir_pagination(config: AppConfig) -> None:
|
||
"""list_dir shows pagination hint when more items are available."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock(
|
||
return_value={
|
||
"total": 200,
|
||
"files": [
|
||
{
|
||
"name": f"file{i}.txt",
|
||
"isdir": False,
|
||
"additional": {"size": 100, "time": {"mtime": 1700000000}},
|
||
}
|
||
for i in range(100)
|
||
],
|
||
}
|
||
)
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
result = await tools["list_dir"](path="/volume1/data", limit=100)
|
||
|
||
assert "Showing 1–100 of 200" in result
|
||
assert "offset=100" in result
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_dir_empty(config: AppConfig) -> None:
|
||
"""list_dir returns a friendly message for empty directories."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock(return_value={"total": 0, "files": []})
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
result = await tools["list_dir"](path="/volume1/empty")
|
||
|
||
assert "empty" in result.lower() or "does not exist" in result.lower()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_dir_invalid_sort_by(config: AppConfig) -> None:
|
||
"""list_dir returns an Error: message for invalid sort_by."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock()
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
result = await tools["list_dir"](path="/volume1/data", sort_by="invalid_field")
|
||
|
||
assert result.startswith("Error:")
|
||
assert "sort_by" in result
|
||
client.request.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_dir_invalid_sort_direction(config: AppConfig) -> None:
|
||
"""list_dir returns an Error: message for invalid sort_direction."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock()
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
result = await tools["list_dir"](path="/volume1/data", sort_direction="random")
|
||
|
||
assert result.startswith("Error:")
|
||
assert "sort_direction" in result
|
||
client.request.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_dir_limit_clamped(config: AppConfig) -> None:
|
||
"""list_dir clamps limit to _MAX_LIMIT (500)."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock(return_value={"total": 1, "files": []})
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
await tools["list_dir"](path="/volume1/data", limit=9999)
|
||
|
||
call_params = client.request.call_args[1]["params"]
|
||
assert call_params["limit"] == 500
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_dir_dsm_error(config: AppConfig) -> None:
|
||
"""list_dir returns Error: on SynologyError (e.g. path not found)."""
|
||
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["list_dir"](path="/volume1/nonexistent")
|
||
|
||
assert result.startswith("Error:")
|
||
assert "not found" in result.lower()
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# get_info
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_info_single_file(config: AppConfig) -> None:
|
||
"""get_info returns a table with metadata for a single file."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock(
|
||
return_value={
|
||
"files": [
|
||
{
|
||
"path": "/dev/notes.txt",
|
||
"name": "notes.txt",
|
||
"isdir": False,
|
||
"additional": {
|
||
"real_path": "/volume1/dev/notes.txt",
|
||
"size": 1024,
|
||
"time": {"mtime": 1700000000, "crtime": 1690000000},
|
||
"owner": {"user": "marcus", "group": "users"},
|
||
"perm": {"posix": 0o644},
|
||
},
|
||
}
|
||
]
|
||
}
|
||
)
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
result = await tools["get_info"](path="/dev/notes.txt")
|
||
|
||
assert "/dev/notes.txt" in result
|
||
assert "file" in result
|
||
assert "1 KB" in result or "1024 B" in result
|
||
assert "marcus" in result
|
||
assert "users" in result
|
||
assert "644" in result
|
||
assert "/volume1/dev/notes.txt" in result
|
||
assert "1 item(s)" in result
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_info_directory(config: AppConfig) -> None:
|
||
"""get_info shows '-' for size of a directory."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock(
|
||
return_value={
|
||
"files": [
|
||
{
|
||
"path": "/dev",
|
||
"name": "dev",
|
||
"isdir": True,
|
||
"additional": {
|
||
"real_path": "/volume1/dev",
|
||
"size": 0,
|
||
"time": {"mtime": 1700000000, "crtime": 1690000000},
|
||
"owner": {"user": "marcus", "group": "users"},
|
||
"perm": {"posix": 0o755},
|
||
},
|
||
}
|
||
]
|
||
}
|
||
)
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
result = await tools["get_info"](path="/dev")
|
||
|
||
assert "dir" in result
|
||
assert "755" in result
|
||
# Size for directory should be "-"
|
||
rows = [line for line in result.splitlines() if "/dev" in line and "|" in line]
|
||
assert rows, "expected a data row containing /dev"
|
||
size_col = rows[0].split("|")[3].strip()
|
||
assert size_col == "-"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_info_multiple_paths(config: AppConfig) -> None:
|
||
"""get_info handles comma-separated paths and returns one row per item."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock(
|
||
return_value={
|
||
"files": [
|
||
{
|
||
"path": "/dev/a.txt",
|
||
"name": "a.txt",
|
||
"isdir": False,
|
||
"additional": {
|
||
"size": 100,
|
||
"time": {"mtime": 1700000000, "crtime": 1690000000},
|
||
"owner": {"user": "marcus", "group": "users"},
|
||
"perm": {"posix": 0o644},
|
||
},
|
||
},
|
||
{
|
||
"path": "/data/b.txt",
|
||
"name": "b.txt",
|
||
"isdir": False,
|
||
"additional": {
|
||
"size": 200,
|
||
"time": {"mtime": 1700000001, "crtime": 1690000001},
|
||
"owner": {"user": "marcus", "group": "users"},
|
||
"perm": {"posix": 0o644},
|
||
},
|
||
},
|
||
]
|
||
}
|
||
)
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
result = await tools["get_info"](path="/dev/a.txt,/data/b.txt")
|
||
|
||
assert "/dev/a.txt" in result
|
||
assert "/data/b.txt" in result
|
||
assert "2 item(s)" in result
|
||
|
||
# Verify the API received paths as a JSON array (confirmed working format)
|
||
call_params = client.request.call_args[1]["params"]
|
||
assert call_params["path"] == json.dumps(["/dev/a.txt", "/data/b.txt"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_info_empty_path(config: AppConfig) -> None:
|
||
"""get_info returns Error: when path is empty."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock()
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
result = await tools["get_info"](path=" ")
|
||
|
||
assert result.startswith("Error:")
|
||
client.request.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_info_dsm_error(config: AppConfig) -> None:
|
||
"""get_info 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["get_info"](path="/dev/missing.txt")
|
||
|
||
assert result.startswith("Error:")
|
||
assert "not found" in result.lower()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_info_uses_getinfo_method(config: AppConfig) -> None:
|
||
"""get_info calls SYNO.FileStation.List with method='getinfo'."""
|
||
client = MagicMock()
|
||
client.request = AsyncMock(return_value={"files": []})
|
||
|
||
tools = _make_mcp_and_tools(config, client)
|
||
await tools["get_info"](path="/dev/file.txt")
|
||
|
||
client.request.assert_called_once()
|
||
call_args = client.request.call_args
|
||
assert call_args[0][0] == "SYNO.FileStation.List"
|
||
assert call_args[0][1] == "getinfo"
|