Initial implementation

This commit is contained in:
2026-04-13 14:22:37 +02:00
commit a0c1b6ed93
26 changed files with 4125 additions and 0 deletions
View File
+236
View File
@@ -0,0 +1,236 @@
"""Tests for modules/compose.py."""
import pytest
from unittest.mock import AsyncMock, patch
import yaml
def make_mock_mcp():
tools: dict = {}
class MockMCP:
def tool(self):
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
return MockMCP(), tools
def make_config():
from mcp_synology_container.config import AppConfig, ConnectionConfig
return AppConfig(
schema_version=1,
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
compose_base_path="/volume1/docker",
)
SAMPLE_COMPOSE = """
services:
web:
image: nginx:1.24
ports:
- "80:80"
environment:
- APP_ENV=production
- LOG_LEVEL=info
db:
image: postgres:15
environment:
POSTGRES_DB: mydb
POSTGRES_USER: admin
"""
@pytest.mark.asyncio
async def test_read_compose():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
# Simulate FileStation.Info success for the first filename
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["read_compose"]("myapp")
assert "nginx:1.24" in result
assert "postgres:15" in result
@pytest.mark.asyncio
async def test_read_compose_not_found():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
# Simulate all FileStation.Info calls failing
from mcp_synology_container.dsm_client import SynologyError
client.request.side_effect = SynologyError("not found", code=408)
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["read_compose"]("nonexistent")
assert "No compose file found" in result
@pytest.mark.asyncio
async def test_update_image_tag_requires_confirmation():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_image_tag"]("myapp", "web", "1.25", confirmed=False)
assert "confirmed=True" in result
assert "nginx:1.24" in result
assert "nginx:1.25" in result
client.upload_text.assert_not_called()
@pytest.mark.asyncio
async def test_update_image_tag_confirmed():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_image_tag"]("myapp", "web", "1.25", confirmed=True)
assert "nginx:1.24 → nginx:1.25" in result
assert "redeploy_project" in result
client.upload_text.assert_called_once()
# Verify the uploaded content has the new tag
uploaded_content = client.upload_text.call_args[0][2]
parsed = yaml.safe_load(uploaded_content)
assert parsed["services"]["web"]["image"] == "nginx:1.25"
@pytest.mark.asyncio
async def test_update_image_tag_service_not_found():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_image_tag"]("myapp", "nonexistent", "1.25", confirmed=True)
assert "not found" in result
assert "web" in result # should list available services
@pytest.mark.asyncio
async def test_update_env_var_new_var_list_format():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_env_var"]("myapp", "web", "NEW_VAR", "value123", confirmed=True)
assert "NEW_VAR=value123" in result
uploaded_content = client.upload_text.call_args[0][2]
parsed = yaml.safe_load(uploaded_content)
env = parsed["services"]["web"]["environment"]
assert any("NEW_VAR=value123" in str(e) for e in env)
@pytest.mark.asyncio
async def test_update_env_var_update_existing_list():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_env_var"]("myapp", "web", "LOG_LEVEL", "debug", confirmed=True)
assert "LOG_LEVEL=debug" in result
uploaded_content = client.upload_text.call_args[0][2]
parsed = yaml.safe_load(uploaded_content)
env = parsed["services"]["web"]["environment"]
assert "LOG_LEVEL=debug" in env
assert "LOG_LEVEL=info" not in env
@pytest.mark.asyncio
async def test_update_env_var_dict_format():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
client.download_text.return_value = SAMPLE_COMPOSE
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
# db service has dict-format environment
result = await tools["update_env_var"]("myapp", "db", "POSTGRES_DB", "newdb", confirmed=True)
assert "POSTGRES_DB=newdb" in result
uploaded_content = client.upload_text.call_args[0][2]
parsed = yaml.safe_load(uploaded_content)
assert parsed["services"]["db"]["environment"]["POSTGRES_DB"] == "newdb"
@pytest.mark.asyncio
async def test_update_compose_invalid_yaml():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_compose"]("myapp", "not: valid: yaml: {{{{", confirmed=True)
assert "Invalid YAML" in result
@pytest.mark.asyncio
async def test_update_compose_missing_services_key():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_compose"]("myapp", "version: '3'\n", confirmed=True)
assert "services" in result
@pytest.mark.asyncio
async def test_update_compose_requires_confirmation():
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
client.request.return_value = {}
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_compose"]("myapp", SAMPLE_COMPOSE, confirmed=False)
assert "confirmed=True" in result
client.upload_text.assert_not_called()
+173
View File
@@ -0,0 +1,173 @@
"""Tests for modules/containers.py."""
import pytest
from unittest.mock import AsyncMock
def make_mock_mcp():
tools: dict = {}
class MockMCP:
def tool(self):
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
return MockMCP(), tools
def make_config():
from mcp_synology_container.config import AppConfig, ConnectionConfig
return AppConfig(
schema_version=1,
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
)
SAMPLE_CONTAINERS_DATA = {
"containers": [
{
"name": "myapp_web",
"status": "running",
"image": "nginx:alpine",
"project_name": "myapp",
},
{
"name": "myapp_db",
"status": "running",
"image": "postgres:15",
"project_name": "myapp",
},
{
"name": "other_svc",
"status": "stopped",
"image": "redis:7",
"project_name": "other",
},
]
}
SAMPLE_LOGS_DATA = {
"logs": [
{
"created": "2025-01-01T10:00:00Z",
"stream": "stdout",
"text": "Server started",
"docid": "1",
},
{
"created": "2025-01-01T10:00:01Z",
"stream": "stderr",
"text": "Warning: deprecated option",
"docid": "2",
},
],
"total": 2,
}
@pytest.mark.asyncio
async def test_list_containers_all():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = SAMPLE_CONTAINERS_DATA
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["list_containers"]()
assert "myapp_web" in result
assert "myapp_db" in result
assert "other_svc" in result
@pytest.mark.asyncio
async def test_list_containers_filtered_by_project():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = SAMPLE_CONTAINERS_DATA
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["list_containers"](project_name="myapp")
assert "myapp_web" in result
assert "myapp_db" in result
assert "other_svc" not in result
@pytest.mark.asyncio
async def test_list_containers_empty():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = {"containers": []}
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["list_containers"]()
assert "No containers found" in result
@pytest.mark.asyncio
async def test_get_container_logs():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = SAMPLE_LOGS_DATA
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["get_container_logs"]("myapp_web", tail=50)
assert "myapp_web" in result
assert "Server started" in result
assert "Warning: deprecated option" in result
@pytest.mark.asyncio
async def test_get_container_logs_with_keyword():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = SAMPLE_LOGS_DATA
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
await tools["get_container_logs"]("myapp_web", tail=100, keyword="error")
call_params = client.request.call_args[1]["params"]
assert call_params["keyword"] == "error"
@pytest.mark.asyncio
async def test_exec_in_container_requires_confirmation():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["exec_in_container"]("myapp_web", "ls /app", confirmed=False)
assert "confirmed=True" in result
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_exec_in_container_confirmed():
from mcp_synology_container.modules.containers import register_containers
client = AsyncMock()
client.request.return_value = {"output": "file1.py\nfile2.py", "exit_code": 0}
mcp, tools = make_mock_mcp()
register_containers(mcp, make_config(), client)
result = await tools["exec_in_container"]("myapp_web", "ls /app", confirmed=True)
assert "file1.py" in result
assert "Exit code: 0" in result
+154
View File
@@ -0,0 +1,154 @@
"""Tests for modules/images.py."""
import pytest
from unittest.mock import AsyncMock
def make_mock_mcp():
tools: dict = {}
class MockMCP:
def tool(self):
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
return MockMCP(), tools
def make_config():
from mcp_synology_container.config import AppConfig, ConnectionConfig
return AppConfig(
schema_version=1,
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
)
SAMPLE_IMAGES = {
"images": [
{
"id": "sha256:aaaa",
"repository": "nginx",
"tags": ["1.24"],
"size": 50 * 1024 * 1024,
"upgradable": True,
},
{
"id": "sha256:bbbb",
"repository": "postgres",
"tags": ["15"],
"size": 80 * 1024 * 1024,
"upgradable": False,
},
{
"id": "sha256:cccc",
"repository": "redis",
"tags": ["7"],
"size": 30 * 1024 * 1024,
"upgradable": False,
},
]
}
@pytest.mark.asyncio
async def test_check_image_updates_all():
from mcp_synology_container.modules.images import register_images
client = AsyncMock()
client.request.return_value = SAMPLE_IMAGES
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["check_image_updates"]()
assert "nginx:1.24" in result
assert "UPDATE AVAILABLE" in result
assert "postgres:15" in result
@pytest.mark.asyncio
async def test_check_image_updates_all_up_to_date():
from mcp_synology_container.modules.images import register_images
client = AsyncMock()
client.request.return_value = {
"images": [
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"], "size": 50 * 1024 * 1024, "upgradable": False},
]
}
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["check_image_updates"]()
assert "All images are up to date" in result
@pytest.mark.asyncio
async def test_check_image_updates_no_images():
from mcp_synology_container.modules.images import register_images
client = AsyncMock()
client.request.return_value = {"images": []}
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["check_image_updates"]()
assert "No images found" in result
@pytest.mark.asyncio
async def test_check_image_updates_api_error():
from mcp_synology_container.modules.images import register_images
from mcp_synology_container.dsm_client import SynologyError
client = AsyncMock()
client.request.side_effect = SynologyError("API unavailable", code=102)
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["check_image_updates"]()
assert "Error" in result
@pytest.mark.asyncio
async def test_check_image_updates_for_project():
from mcp_synology_container.modules.images import register_images
project_list = {
"uuid-1": {
"id": "uuid-1",
"name": "myapp",
"status": "RUNNING",
"containerIds": ["abc123"],
}
}
project_detail = {
"containers": [
{"Image": "sha256:aaaa", "Config": {"Image": "nginx:1.24"}},
]
}
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image":
return SAMPLE_IMAGES
if api == "SYNO.Docker.Project" and method == "list":
return project_list
if api == "SYNO.Docker.Project" and method == "get":
return project_detail
return {}
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_images(mcp, make_config(), client)
result = await tools["check_image_updates"](project_name="myapp")
assert "myapp" in result
assert "nginx:1.24" in result
+163
View File
@@ -0,0 +1,163 @@
"""Tests for modules/projects.py."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from mcp_synology_container.modules.projects import _find_project, _format_project_detail
SAMPLE_PROJECTS = {
"uuid-1": {
"id": "uuid-1",
"name": "myapp",
"status": "RUNNING",
"path": "/volume1/docker/myapp",
"share_path": "/docker/myapp",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-02T00:00:00Z",
"containerIds": ["abc123def456"],
"services": [{"display_name": "myapp (project)"}],
},
"uuid-2": {
"id": "uuid-2",
"name": "database",
"status": "STOPPED",
"path": "/volume1/docker/database",
"share_path": "/docker/database",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z",
"containerIds": [],
"services": [],
},
}
@pytest.mark.asyncio
async def test_find_project_found():
client = AsyncMock()
client.request.return_value = SAMPLE_PROJECTS
result = await _find_project(client, "myapp")
assert result is not None
assert result["name"] == "myapp"
assert result["status"] == "RUNNING"
@pytest.mark.asyncio
async def test_find_project_not_found():
client = AsyncMock()
client.request.return_value = SAMPLE_PROJECTS
result = await _find_project(client, "nonexistent")
assert result is None
@pytest.mark.asyncio
async def test_find_project_api_error():
client = AsyncMock()
client.request.side_effect = Exception("API error")
result = await _find_project(client, "myapp")
assert result is None
def test_format_project_detail():
project = SAMPLE_PROJECTS["uuid-1"]
output = _format_project_detail(project)
assert "myapp" in output
assert "RUNNING" in output
assert "/volume1/docker/myapp" in output
assert "uuid-1" in output
def test_format_project_detail_no_containers():
project = SAMPLE_PROJECTS["uuid-2"]
output = _format_project_detail(project)
assert "database" in output
assert "STOPPED" in output
assert "Containers: 0" in output
@pytest.mark.asyncio
async def test_list_projects_tool():
"""Test list_projects tool via function registration."""
from mcp_synology_container.modules.projects import register_projects
from mcp_synology_container.config import AppConfig, ConnectionConfig
config = AppConfig(
schema_version=1,
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
)
client = AsyncMock()
client.request.return_value = SAMPLE_PROJECTS
tools: dict = {}
class MockMCP:
def tool(self):
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
register_projects(MockMCP(), config, client)
assert "list_projects" in tools
result = await tools["list_projects"]()
assert "myapp" in result
assert "database" in result
assert "RUNNING" in result
@pytest.mark.asyncio
async def test_stop_project_requires_confirmation():
from mcp_synology_container.modules.projects import register_projects
from mcp_synology_container.config import AppConfig, ConnectionConfig
config = AppConfig(
schema_version=1,
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
)
client = AsyncMock()
tools: dict = {}
class MockMCP:
def tool(self):
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
register_projects(MockMCP(), config, client)
result = await tools["stop_project"]("myapp", confirmed=False)
assert "confirmed=True" in result
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_redeploy_project_requires_confirmation():
from mcp_synology_container.modules.projects import register_projects
from mcp_synology_container.config import AppConfig, ConnectionConfig
config = AppConfig(
schema_version=1,
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
)
client = AsyncMock()
tools: dict = {}
class MockMCP:
def tool(self):
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
register_projects(MockMCP(), config, client)
result = await tools["redeploy_project"]("myapp", confirmed=False)
assert "confirmed=True" in result
client.request.assert_not_called()