feat: implement auth infrastructure and first two FileStation tools
- auth.py: AuthManager with OS keyring, env var fallback, 2FA device token flow - config.py: AppConfig/ConnectionConfig dataclasses, YAML load/save, env overrides - client.py: FileStationClient with lazy init, session re-auth, upload/download - cli.py: setup / check / serve subcommands (anyio.run throughout) - server.py: create_server factory wiring FastMCP to FileStation tools - tools/filestation.py: list_shares and list_dir with ASCII table output, pagination hints, input validation, DSM error mapping - tests: 30 unit tests, all passing (auth, config, tools) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
"""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 "/volume1/data" 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 files and directories."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(
|
||||
return_value={
|
||||
"total": 3,
|
||||
"files": [
|
||||
{
|
||||
"name": "documents",
|
||||
"isdir": True,
|
||||
"additional": {"size": None, "time": {"mtime": 1700000000}},
|
||||
},
|
||||
{
|
||||
"name": "photo.jpg",
|
||||
"isdir": False,
|
||||
"additional": {"size": 2_500_000, "time": {"mtime": 1700100000}},
|
||||
},
|
||||
{
|
||||
"name": "readme.txt",
|
||||
"isdir": False,
|
||||
"additional": {"size": 1024, "time": {"mtime": 1700200000}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["list_dir"](path="/volume1/data")
|
||||
|
||||
assert "documents" in result
|
||||
assert "photo.jpg" in result
|
||||
assert "readme.txt" in result
|
||||
assert "dir" in result
|
||||
assert "file" 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 + i}},
|
||||
}
|
||||
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()
|
||||
Reference in New Issue
Block a user