Files
mcp-synology-filestation/tests/test_tools_filestation.py
T
marcus 014af1aefe feat: add get_info tool using SYNO.FileStation.List::getinfo
SYNO.FileStation.Stat is absent from this NAS's API registry.
SYNO.FileStation.List::getinfo returns identical data and is confirmed
working.

- tools/filestation.py: new get_info tool — accepts one or more
  comma-separated paths, calls getinfo with real_path/size/time/perm/
  owner/type additional fields, returns a 9-column table
- tests: 6 new tests covering single file, directory, multi-path,
  empty input, DSM error, and correct API method assertion
- SPEC.md: remove SYNO.FileStation.Stat from API table, rewrite get_info
  tool section to reference getinfo, update list_dir note
- CLAUDE.md: update Implemented Tools list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 09:28:04 +02:00

416 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for tools/filestation.py: list_shares and list_dir."""
from __future__ import annotations
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 13 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 1100 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 both paths as a comma-joined string
call_params = client.request.call_args[1]["params"]
assert call_params["path"] == "/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"