c8cda5ef2b
Bug 1: Container name hash-prefix (e.g. f93cb8b504f7_jenkins) - _strip_hash_prefix(): strips 12-char hex prefix and leading slash - _resolve_container_name(): looks up actual DSM name from container list - Applied in list_containers (display), container_stats (matching), get_container_status/get_container_logs/exec_in_container (lookup) Bug 2: redeploy_project DSM 2101/1202 on wrong project state - Fetch project status before acting - RUNNING → stop then start - STOPPED → start directly (nothing to stop) - BUILD_FAILED → suppress stop error, then start - Other → return error with workaround hint 36 tests all passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
311 lines
9.2 KiB
Python
311 lines
9.2 KiB
Python
"""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()
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Bug 2: status-aware redeploy
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def make_projects_tools(client):
|
|
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),
|
|
)
|
|
tools: dict = {}
|
|
|
|
class MockMCP:
|
|
def tool(self):
|
|
def decorator(fn):
|
|
tools[fn.__name__] = fn
|
|
return fn
|
|
return decorator
|
|
|
|
register_projects(MockMCP(), config, client)
|
|
return tools
|
|
|
|
|
|
def project_list(status: str) -> dict:
|
|
return {
|
|
"uuid-1": {
|
|
"id": "uuid-1",
|
|
"name": "myapp",
|
|
"status": status,
|
|
"path": "/volume1/docker/myapp",
|
|
"containerIds": ["abc123"],
|
|
"services": [],
|
|
}
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_running_project():
|
|
"""RUNNING project: stop then start (2 steps)."""
|
|
client = AsyncMock()
|
|
calls = []
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
calls.append((api, method))
|
|
return project_list("RUNNING") if method == "list" else {}
|
|
|
|
client.request.side_effect = mock_request
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "redeployed successfully" in result
|
|
methods = [m for _, m in calls]
|
|
assert "stop" in methods
|
|
assert "start" in methods
|
|
# stop must come before start
|
|
assert methods.index("stop") < methods.index("start")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_stopped_project_starts_directly():
|
|
"""STOPPED project: skip stop, just start."""
|
|
client = AsyncMock()
|
|
calls = []
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
calls.append((api, method))
|
|
return project_list("STOPPED") if method == "list" else {}
|
|
|
|
client.request.side_effect = mock_request
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "redeployed successfully" in result
|
|
methods = [m for _, m in calls]
|
|
assert "stop" not in methods
|
|
assert "start" in methods
|
|
assert "STOPPED" in result or "starting directly" in result.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_build_failed_project():
|
|
"""BUILD_FAILED project: force-stop (non-fatal), then start."""
|
|
client = AsyncMock()
|
|
calls = []
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
calls.append((api, method))
|
|
return project_list("BUILD_FAILED") if method == "list" else {}
|
|
|
|
client.request.side_effect = mock_request
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "redeployed successfully" in result
|
|
methods = [m for _, m in calls]
|
|
assert "stop" in methods
|
|
assert "start" in methods
|
|
assert methods.index("stop") < methods.index("start")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_build_failed_stop_error_nonfatal():
|
|
"""BUILD_FAILED: stop failure must not abort the redeploy."""
|
|
from mcp_synology_container.dsm_client import SynologyError
|
|
|
|
client = AsyncMock()
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
if method == "list":
|
|
return project_list("BUILD_FAILED")
|
|
if method == "stop":
|
|
raise SynologyError("already stopped", code=2101)
|
|
return {} # start succeeds
|
|
|
|
client.request.side_effect = mock_request
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "redeployed successfully" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_unknown_status_returns_error():
|
|
"""Unknown status must return a clear error with a workaround hint."""
|
|
client = AsyncMock()
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
if method == "list":
|
|
return project_list("UPDATING")
|
|
return {}
|
|
|
|
client.request.side_effect = mock_request
|
|
tools = make_projects_tools(client)
|
|
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "UPDATING" in result
|
|
assert "Workaround" in result or "stop_project" in result
|