Files
mcp-synology-container/tests/test_modules/test_projects.py
T
marcus 13e10fa52f feat: v0.3.0 — review welle 2 (M-4, M-5, M-6)
Three resilience and honesty fixes from the v0.2.8 review. Minor
version bump because redeploy_project and system_prune return
different strings.

M-4: trigger_build_stream now converts every non-ReadTimeout
httpx.HTTPError (ConnectError, ConnectTimeout, WriteError,
RemoteProtocolError, ...) into a SynologyError with a clear
message. Previously only ReadTimeout was handled; everything else
propagated as a raw httpx exception. redeploy_project now tracks
whether stop was actually issued and, when build_stream fails after
a successful stop, tells the user the project is in STOPPED state
and recommends start_project / retry rather than the misleading
"use stop + start separately" workaround.

M-5: _wait_for_project_running exits early on BUILD_FAILED / ERROR
(new _TERMINAL_FAILURE_STATUSES frozenset). DSM signals these
statuses within seconds of a failed image pull; the old polling
loop kept waiting up to 5 minutes for RUNNING. redeploy_project
now surfaces the terminal status with a BUILD_FAILED-specific hint
to update_image_tag.

M-6: system_prune preview now enumerates user-created networks
that have no containers attached (excluding the three built-in
networks bridge/host/none, which Docker never prunes). Previously
the preview noted "Unused networks: (not counted)" even though
SYNO.Docker.Utils/prune does delete them — users could lose
networks they had not been warned about.

Tests:
- 2 new dsm_client tests: ConnectError and RemoteProtocolError
  both raise SynologyError, not raw httpx exceptions.
- 2 new project tests: recovery hint after stop+build_stream
  failure (RUNNING case); old workaround retained for the
  STOPPED case where no stop was issued.
- 3 new polling tests: BUILD_FAILED and ERROR each trigger early
  exit; redeploy_project surfaces BUILD_FAILED with update_image_tag
  hint.
- 2 new system_prune preview tests: counts unused networks
  correctly, excludes built-ins; network-fetch failure is non-fatal.

245 tests pass. ruff check + ruff format clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:57:20 +02:00

520 lines
18 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,
build_stream_raises=None,
):
"""Create a stateful client mock for redeploy tests.
Returns (client, calls_list). After ``trigger_build_stream`` 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 = []
build_done = False
async def mock_request(api, method, **kwargs):
calls.append((api, method))
if method == "stop" and stop_raises:
raise stop_raises
if method == "list":
return project_list("RUNNING") if build_done else project_list(initial_status)
return {}
async def mock_trigger_build_stream(project_id):
nonlocal build_done
calls.append(("SYNO.Docker.Project", "build_stream"))
if build_stream_raises:
raise build_stream_raises
build_done = True # After build_stream, polling returns RUNNING
client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_trigger_build_stream)
return client, calls
@pytest.mark.asyncio
async def test_redeploy_running_project():
"""RUNNING project: stop → build_stream → poll 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 "build_stream" in methods
assert methods.index("stop") < methods.index("build_stream")
@pytest.mark.asyncio
async def test_redeploy_stopped_project_skips_stop():
"""STOPPED project: skip stop, call build_stream directly; 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 "build_stream" in methods
assert "STOPPED" in result
@pytest.mark.asyncio
async def test_redeploy_build_failed_project():
"""BUILD_FAILED project: stop (suppressed) → build_stream → poll 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 "build_stream" in methods
assert methods.index("stop") < methods.index("build_stream")
@pytest.mark.asyncio
async def test_redeploy_build_failed_stop_error_nonfatal():
"""BUILD_FAILED: stop failure is non-fatal — build_stream must still be called."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_stateful_redeploy_mock(
"BUILD_FAILED",
stop_raises=SynologyError("already stopped", code=2101),
)
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 "build_stream" in methods
@pytest.mark.asyncio
async def test_redeploy_build_stream_error_aborts():
"""If build_stream raises, redeploy must abort with a clear error message."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_stateful_redeploy_mock(
"RUNNING",
build_stream_raises=SynologyError("build failed", 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 "build failed" in result or "Error during redeploy" in result
# Polling must not have been called after build_stream failure
methods = [m for _, m in calls]
list_calls = [m for m in methods if m == "list"]
assert len(list_calls) <= 1 # at most the initial find_project call
@pytest.mark.asyncio
async def test_redeploy_poll_timeout():
"""If project never reaches RUNNING after build_stream, a warning is emitted."""
client = AsyncMock()
build_done = False
async def mock_request(api, method, **kwargs):
if method == "list":
# Before build: RUNNING (so initial status check is valid)
# After build: STARTING (simulate stuck containers)
return project_list("STARTING") if build_done else project_list("RUNNING")
return {}
async def mock_build_stream(project_id):
nonlocal build_done
build_done = True
client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
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._BUILD_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
client.trigger_build_stream = AsyncMock()
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
# ──────────────────────────────────────────────────────────────────────
# M-4: clear recovery hint when build_stream fails after stop succeeded
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_redeploy_build_stream_transport_error_shows_stopped_recovery_hint():
"""M-4: build_stream transport error after RUNNING-stop must tell the user the
project is now STOPPED and recommend start_project / retry."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_stateful_redeploy_mock(
"RUNNING",
build_stream_raises=SynologyError(
"build_stream transport error: ConnectError: nas offline", code=0
),
)
tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True)
# No raw stack trace — clean message
assert "transport error" in result
assert "ConnectError" in result
# The recovery hint must point at the actual situation
assert "STOPPED" in result
assert "start_project" in result
# Old misleading workaround text must NOT appear
assert "stop_project + start_project separately" not in result
@pytest.mark.asyncio
async def test_redeploy_build_stream_error_on_stopped_project_keeps_old_workaround():
"""If the project was STOPPED to begin with, no stop was issued, so the
'STOPPED recovery' hint is NOT appropriate — keep the original workaround."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_stateful_redeploy_mock(
"STOPPED",
build_stream_raises=SynologyError("build failed", 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 "build failed" in result or "Error during redeploy" in result
# Stop was never issued; new recovery hint should not appear
assert "was stopped before this error" not in result
# ──────────────────────────────────────────────────────────────────────
# M-5: polling exits early on BUILD_FAILED / ERROR
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_wait_for_project_running_returns_early_on_build_failed():
"""_wait_for_project_running must exit as soon as DSM reports BUILD_FAILED,
not wait the full timeout."""
from mcp_synology_container.modules.projects import _wait_for_project_running
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if method == "list":
return project_list("BUILD_FAILED")
return {}
client.request.side_effect = mock_request
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
# 100s timeout, 2s interval — if the early-exit isn't there the test
# would still terminate quickly because sleep is mocked, but the call
# count assertion below catches a non-exiting loop.
result = await _wait_for_project_running(client, "myapp", timeout=100, interval=2)
assert result == "BUILD_FAILED"
# Only a few list() calls — exit was on the first poll iteration.
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
assert len(list_calls) <= 2
@pytest.mark.asyncio
async def test_wait_for_project_running_returns_early_on_error():
from mcp_synology_container.modules.projects import _wait_for_project_running
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if method == "list":
return project_list("ERROR")
return {}
client.request.side_effect = mock_request
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await _wait_for_project_running(client, "myapp", timeout=100, interval=2)
assert result == "ERROR"
@pytest.mark.asyncio
async def test_redeploy_surfaces_build_failed_with_hint():
"""When polling reports BUILD_FAILED, redeploy_project must include a clear
hint to inspect the image tag and retry."""
client = AsyncMock()
build_done = False
async def mock_request(api, method, **kwargs):
if method == "list":
return project_list("BUILD_FAILED") if build_done else project_list("RUNNING")
return {}
async def mock_build_stream(project_id):
nonlocal build_done
build_done = True
client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
tools = make_projects_tools(client)
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "Redeploy failed" in result
assert "BUILD_FAILED" in result
assert "update_image_tag" in result
assert "redeployed successfully" not in result
# Polling must have exited early, not run to the full timeout.
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
# Generous upper bound — early exit means handful of polls, not hundreds.
assert len(list_calls) <= 5