v0.2.1: redeploy_project post-start polling (30s timeout)
DSM starts containers asynchronously - start_project returns immediately while containers are still initialising. Adds _wait_for_project_running: polls SYNO.Docker.Project/list every 2s up to 30s after issuing start. Reports RUNNING on success; emits a warning instead of failure on timeout so callers can still verify with get_project_status. Applies to all three redeploy paths (RUNNING, STOPPED, BUILD_FAILED). Also bumps version 0.2.0 → 0.2.1 and adds CHANGELOG entry. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
"""Tests for modules/projects.py."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
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",
|
||||
@@ -83,8 +83,8 @@ def test_format_project_detail_no_containers():
|
||||
@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
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
@@ -114,8 +114,8 @@ async def test_list_projects_tool():
|
||||
|
||||
@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
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
@@ -141,8 +141,8 @@ async def test_stop_project_requires_confirmation():
|
||||
|
||||
@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
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
@@ -172,8 +172,8 @@ async def test_redeploy_project_requires_confirmation():
|
||||
|
||||
|
||||
def make_projects_tools(client):
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
@@ -206,43 +206,58 @@ def project_list(status: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_running_project():
|
||||
"""RUNNING project: stop then start (2 steps)."""
|
||||
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))
|
||||
return project_list("RUNNING") if method == "list" else {}
|
||||
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)
|
||||
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
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
|
||||
# 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
|
||||
"""STOPPED project: skip stop, just start; polls until RUNNING."""
|
||||
client, calls = make_stateful_redeploy_mock("STOPPED")
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
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]
|
||||
@@ -253,51 +268,69 @@ async def test_redeploy_stopped_project_starts_directly():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_build_failed_project():
|
||||
"""BUILD_FAILED project: stop, pull images, then start (3 steps)."""
|
||||
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
|
||||
"""BUILD_FAILED project: stop → pull → start; polls until RUNNING."""
|
||||
client, calls = make_stateful_redeploy_mock("BUILD_FAILED")
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
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 # New: pull step
|
||||
assert "pull" in methods
|
||||
assert "start" in methods
|
||||
# Order: stop → pull → start
|
||||
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/pull failure must not abort the redeploy."""
|
||||
"""BUILD_FAILED: stop/pull failures 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=SynologyError("pull failed", code=2102),
|
||||
)
|
||||
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_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":
|
||||
return project_list("BUILD_FAILED")
|
||||
if method == "stop":
|
||||
raise SynologyError("already stopped", code=2101)
|
||||
if method == "pull":
|
||||
raise SynologyError("pull failed", code=2102)
|
||||
return {} # start succeeds
|
||||
# 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)
|
||||
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
# 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 "redeployed successfully" in result
|
||||
assert "Warning" in result
|
||||
assert "redeployed successfully" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user