7de4b56962
Remove contextlib.suppress from the image pull step in the BUILD_FAILED redeploy path. A failed pull (e.g. non-existent tag) now immediately returns an actionable error pointing to update_image_tag instead of silently continuing and starting the project with stale/missing image. Also bumps version 0.2.1 → 0.2.2 and adds CHANGELOG entry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
376 lines
12 KiB
Python
376 lines
12 KiB
Python
"""Tests for modules/projects.py."""
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
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.config import AppConfig, ConnectionConfig
|
|
from mcp_synology_container.modules.projects import register_projects
|
|
|
|
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.config import AppConfig, ConnectionConfig
|
|
from mcp_synology_container.modules.projects import register_projects
|
|
|
|
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.config import AppConfig, ConnectionConfig
|
|
from mcp_synology_container.modules.projects import register_projects
|
|
|
|
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.config import AppConfig, ConnectionConfig
|
|
from mcp_synology_container.modules.projects import register_projects
|
|
|
|
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": [],
|
|
}
|
|
}
|
|
|
|
|
|
def make_stateful_redeploy_mock(initial_status: str, stop_raises=None, pull_raises=None):
|
|
"""Create a stateful client mock for redeploy tests.
|
|
|
|
Returns (client, calls_list). After ``start`` is called, subsequent
|
|
``list`` calls return RUNNING so the polling loop terminates immediately.
|
|
asyncio.sleep is NOT patched here — patch it at call-site.
|
|
"""
|
|
client = AsyncMock()
|
|
calls = []
|
|
start_called = False
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
nonlocal start_called
|
|
calls.append((api, method))
|
|
if method == "start":
|
|
start_called = True
|
|
if method == "stop" and stop_raises:
|
|
raise stop_raises
|
|
if method == "pull" and pull_raises:
|
|
raise pull_raises
|
|
if method == "list":
|
|
return project_list("RUNNING") if start_called else project_list(initial_status)
|
|
return {}
|
|
|
|
client.request.side_effect = mock_request
|
|
return client, calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_running_project():
|
|
"""RUNNING project: stop then start; polls until RUNNING."""
|
|
client, calls = make_stateful_redeploy_mock("RUNNING")
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
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_stopped_project_starts_directly():
|
|
"""STOPPED project: skip stop, just start; polls until RUNNING."""
|
|
client, calls = make_stateful_redeploy_mock("STOPPED")
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
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: stop → pull → start; polls until RUNNING."""
|
|
client, calls = make_stateful_redeploy_mock("BUILD_FAILED")
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "redeployed successfully" in result
|
|
methods = [m for _, m in calls]
|
|
assert "stop" in methods
|
|
assert "pull" in methods
|
|
assert "start" in methods
|
|
assert methods.index("stop") < methods.index("pull")
|
|
assert methods.index("pull") < methods.index("start")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_build_failed_stop_error_nonfatal():
|
|
"""BUILD_FAILED: stop failure is non-fatal and must not abort the redeploy."""
|
|
from mcp_synology_container.dsm_client import SynologyError
|
|
|
|
client, _ = make_stateful_redeploy_mock(
|
|
"BUILD_FAILED",
|
|
stop_raises=SynologyError("already stopped", code=2101),
|
|
pull_raises=None, # pull succeeds
|
|
)
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "redeployed successfully" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_build_failed_pull_error_aborts():
|
|
"""BUILD_FAILED: pull failure must abort redeploy with a clear message."""
|
|
from mcp_synology_container.dsm_client import SynologyError
|
|
|
|
client, calls = make_stateful_redeploy_mock(
|
|
"BUILD_FAILED",
|
|
stop_raises=None,
|
|
pull_raises=SynologyError("image not found", code=114),
|
|
)
|
|
tools = make_projects_tools(client)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "redeployed successfully" not in result
|
|
assert "Aborted" in result or "pull failed" in result.lower()
|
|
assert "compose.yaml" in result or "update_image_tag" in result
|
|
# start must NOT have been called after a pull failure
|
|
methods = [m for _, m in calls]
|
|
assert "start" not in methods
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redeploy_poll_timeout():
|
|
"""If project never reaches RUNNING after start, a warning is emitted."""
|
|
client = AsyncMock()
|
|
start_called = False
|
|
|
|
async def mock_request(api, method, **kwargs):
|
|
nonlocal start_called
|
|
if method == "start":
|
|
start_called = True
|
|
if method == "list":
|
|
# Before start: return RUNNING so initial status check picks a valid path.
|
|
# After start: return STARTING to simulate a stuck container — triggers timeout.
|
|
return project_list("STARTING") if start_called else project_list("RUNNING")
|
|
return {}
|
|
|
|
client.request.side_effect = mock_request
|
|
tools = make_projects_tools(client)
|
|
|
|
# Use tiny timeout so the test is instant (interval=1, timeout=1 → 1 poll)
|
|
with (
|
|
patch("mcp_synology_container.modules.projects.asyncio.sleep"),
|
|
patch("mcp_synology_container.modules.projects._POLL_TIMEOUT", 1),
|
|
patch("mcp_synology_container.modules.projects._POLL_INTERVAL", 1),
|
|
):
|
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
|
|
|
assert "Warning" in result
|
|
assert "redeployed successfully" not 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
|