Files
mcp-synology-container/tests/test_modules/test_projects.py
T
marcus f6f550fdcb fix: v0.10.0 — compose up after cached build; recover CREATED projects
A build: project whose image was already cached landed in CREATED without
its containers being started: build_stream runs `docker compose build`, and
on a cache hit DSM's implicit `up` does not fire, leaving the project stuck
(start_project -> DSM 2104, redeploy_project -> "unexpected status CREATED").

- create_project / redeploy_project: after build_stream, read status and, if
  not RUNNING, issue SYNO.Docker.Project/start (= docker compose up) via new
  _ensure_project_up helper, then re-read. Best-effort: a rejected start is
  surfaced as a clear "containers are not running" hint, not a silent stick.
- redeploy_project: accept CREATED as an input status and recover it.
- start_project: support CREATED/STOPPED; graceful 2104 hint with the
  cold-rebuild workaround; no-op message when already RUNNING.

Confirmed against live DSM: start brings STOPPED -> RUNNING; the cold-build
path was already RUNNING and is unchanged. 329 tests pass; ruff clean.
2026-06-23 15:28:41 +00:00

1179 lines
42 KiB
Python

"""Tests for modules/projects.py."""
from unittest.mock import AsyncMock
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,
build_stream_log: str = "",
):
"""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 post-build status read sees
a finished, running project.
"""
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, **kwargs):
nonlocal build_done
calls.append(("SYNO.Docker.Project", "build_stream"))
if build_stream_raises:
raise build_stream_raises
build_done = True # After build_stream, the status read returns RUNNING
return build_stream_log
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 → status read reports RUNNING."""
client, calls = make_stateful_redeploy_mock("RUNNING")
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 "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; status read RUNNING."""
client, calls = make_stateful_redeploy_mock("STOPPED")
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 "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 → status read RUNNING."""
client, calls = make_stateful_redeploy_mock("BUILD_FAILED")
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 "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)
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)
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_large_image_returns_background_hint():
"""When build_stream returns the timeout marker (early window elapsed, large
image still pulling), redeploy returns immediately with a 'check status' hint
and does NOT block on a polling loop."""
from mcp_synology_container.dsm_client import DsmClient
client, calls = make_stateful_redeploy_mock(
"RUNNING",
build_stream_log=DsmClient.BUILD_STREAM_TIMEOUT_MARKER,
)
tools = make_projects_tools(client)
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "redeploy started" in result
assert "get_project_status" in result
assert "redeployed successfully" not in result
# No post-build status read — the background branch returns immediately.
# Only the initial _find_project lookup hit Project/list.
methods = [m for _, m in calls]
assert [m for m in methods if m == "list"] == ["list"]
@pytest.mark.asyncio
async def test_redeploy_unknown_status_returns_error():
"""Unknown status must return a clear error pointing back at redeploy_project."""
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(return_value="")
tools = make_projects_tools(client)
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "UPDATING" in result
assert "redeploy_project" in result
assert "does NOT recreate" 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)
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)
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
# ──────────────────────────────────────────────────────────────────────
# Issue #2: surface build_stream daemon errors directly
# ──────────────────────────────────────────────────────────────────────
def test_parse_build_stream_log_extracts_errors_and_info():
"""The helper splits a streamed build log into error and info lines."""
from mcp_synology_container.modules.projects import _parse_build_stream_log
log = (
"Container vault Pulling\n"
"nginx Error\n"
"Error response from daemon: manifest for nginx:9.9.9 not found: "
"manifest unknown\n"
"\n"
"Container vault Running\n"
)
errors, info = _parse_build_stream_log(log)
assert errors == [
"nginx Error",
("Error response from daemon: manifest for nginx:9.9.9 not found: manifest unknown"),
]
assert info == ["Container vault Pulling", "Container vault Running"]
def test_parse_build_stream_log_clean_log_no_errors():
from mcp_synology_container.modules.projects import _parse_build_stream_log
log = "Container vault Running\nContainer vault-db Running\n"
errors, info = _parse_build_stream_log(log)
assert errors == []
assert info == ["Container vault Running", "Container vault-db Running"]
@pytest.mark.asyncio
async def test_redeploy_surfaces_build_stream_daemon_error():
"""build_stream log containing 'Error response from daemon:' aborts redeploy
with that line visible — and skips the polling step entirely."""
client, calls = make_stateful_redeploy_mock(
"RUNNING",
build_stream_log=(
"nginx Error\n"
"Error response from daemon: manifest for nginx:9.9.9-nonexistent "
"not found: manifest unknown\n"
),
)
tools = make_projects_tools(client)
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "Build failed" in result
assert "Error response from daemon: manifest for nginx:9.9.9-nonexistent" in result
assert "redeploy aborted" in result
assert "redeployed successfully" not in result
# Recovery hint must appear because the project was stopped first.
assert "was stopped before this error" in result
# The polling step must NOT run — build_stream errors short-circuit.
methods = [m for _, m in calls]
list_calls = [m for m in methods if m == "list"]
assert len(list_calls) == 1 # only the initial _find_project lookup
@pytest.mark.asyncio
async def test_redeploy_clean_log_reports_success():
"""A clean, closed build_stream log → status read RUNNING → success message."""
client, calls = make_stateful_redeploy_mock(
"RUNNING",
build_stream_log="Container myapp Pulling\nContainer myapp Running\n",
)
tools = make_projects_tools(client)
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "redeployed successfully" in result
@pytest.mark.asyncio
async def test_create_project_surfaces_build_stream_daemon_error():
"""build_stream log with daemon error → registered-but-failed-to-build hint."""
client, calls = make_create_project_client(
build_stream_log=(
"web Error\n"
"Error response from daemon: manifest for nginx:0.0.0-bad not found: "
"manifest unknown\n"
),
)
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "Build failed" in result
assert "Error response from daemon: manifest for nginx:0.0.0-bad" in result
assert "is registered but failed to build" in result
assert "redeploy_project" in result
assert "created and started successfully" not in result
# ──────────────────────────────────────────────────────────────────────
# Post-build status read surfaces BUILD_FAILED / ERROR
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_redeploy_surfaces_build_failed_with_hint():
"""When the post-build status read 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, **kwargs):
nonlocal build_done
build_done = True
return "" # Clean stream log — failure surfaces via the status read.
client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
tools = make_projects_tools(client)
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
# ──────────────────────────────────────────────────────────────────────
# create_project
# ──────────────────────────────────────────────────────────────────────
SIMPLE_COMPOSE = """
services:
web:
image: nginx:1.25
worker:
image: redis:7
"""
def make_create_project_client(
*,
existing_projects: dict | None = None,
create_folder_raises: Exception | None = None,
create_project_raises: Exception | None = None,
build_stream_raises: Exception | None = None,
build_stream_log: str = "",
project_id: str = "uuid-new",
final_status: str = "RUNNING",
start_raises: Exception | None = None,
status_after_start: str = "RUNNING",
):
"""Build a stateful mock client for create_project tests.
Tracks:
- whether Docker.Project/create has been called (so post_create_calls
to /list return the newly-registered project at `final_status`)
- whether Docker.Project/start has been called (so subsequent /list
reads return `status_after_start` — used to exercise the
compose-up-after-build recovery for a CREATED build)
- which API/method/version each call used
"""
client = AsyncMock()
calls: list[tuple[str, str, dict]] = []
project_created = False
started = False
async def mock_request(api, method, version=None, params=None, **kwargs):
nonlocal started
calls.append((api, method, dict(params or {})))
if api == "SYNO.Docker.Project" and method == "list":
if project_created:
status = status_after_start if started else final_status
return {
project_id: {
"id": project_id,
"name": "newapp",
"status": status,
"path": "/volume1/docker/newapp",
"containerIds": [],
"services": [],
}
}
return existing_projects or {}
if api == "SYNO.Docker.Project" and method == "start":
if start_raises:
raise start_raises
started = True
return {}
if api == "SYNO.FileStation.CreateFolder":
if create_folder_raises:
raise create_folder_raises
return {}
return {}
async def mock_post_request(api, method, version=None, params=None, **kwargs):
nonlocal project_created
calls.append((api, f"POST:{method}", dict(params or {})))
if api == "SYNO.Docker.Project" and method == "create":
if create_project_raises:
raise create_project_raises
project_created = True
return {"id": project_id}
return {}
async def mock_build_stream(pid, **kwargs):
calls.append(("SYNO.Docker.Project", "build_stream", {"id": pid}))
if build_stream_raises:
raise build_stream_raises
return build_stream_log
client.request.side_effect = mock_request
client.post_request.side_effect = mock_post_request
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
return client, calls
@pytest.mark.asyncio
async def test_create_project_preview_only():
"""Without confirmed=True, no side effects — return a preview with service count."""
client, calls = make_create_project_client()
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE)
assert "confirmed=True" in result
assert "newapp" in result
assert "Services: 2" in result
assert "/docker/newapp" in result
# No CreateFolder, no Project/create, no build_stream
methods = [m for _, m, _ in calls]
assert "create" not in methods # FileStation.CreateFolder
assert "POST:create" not in methods
assert "build_stream" not in methods
@pytest.mark.asyncio
async def test_create_project_rejects_invalid_name():
"""Path-traversal-style names are rejected before any I/O."""
client, calls = make_create_project_client()
tools = make_projects_tools(client)
result = await tools["create_project"]("../escape", SIMPLE_COMPOSE, confirmed=True)
assert "invalid project name" in result.lower()
# No API calls at all
assert calls == []
@pytest.mark.asyncio
async def test_create_project_rejects_invalid_yaml():
"""Malformed compose content is rejected before any I/O."""
client, calls = make_create_project_client()
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", "this: is: not: yaml: [", confirmed=True)
assert "Invalid YAML" in result or "Invalid compose" in result
assert calls == []
@pytest.mark.asyncio
async def test_create_project_rejects_compose_without_services():
client, calls = make_create_project_client()
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", "version: '3'\n", confirmed=True)
assert "services" in result.lower()
assert calls == []
@pytest.mark.asyncio
async def test_create_project_already_exists():
"""If a project with the given name already exists, abort without creating anything."""
existing = {
"uuid-1": {
"id": "uuid-1",
"name": "newapp",
"status": "RUNNING",
"path": "/volume1/docker/newapp",
"containerIds": [],
"services": [],
}
}
client, calls = make_create_project_client(existing_projects=existing)
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "already exists" in result
assert "RUNNING" in result
# Only the list call should have happened
methods = [m for _, m, _ in calls]
assert "create" not in methods
assert "POST:create" not in methods
client.trigger_build_stream.assert_not_called()
@pytest.mark.asyncio
async def test_create_project_happy_path():
"""confirmed=True with no existing project: folder → create → build_stream → RUNNING."""
client, calls = make_create_project_client()
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "created and started successfully" in result
# Verify all three steps fired in the correct order
summarised = [(api, method) for api, method, _ in calls]
assert ("SYNO.FileStation.CreateFolder", "create") in summarised
assert ("SYNO.Docker.Project", "POST:create") in summarised
assert ("SYNO.Docker.Project", "build_stream") in summarised
cf_idx = summarised.index(("SYNO.FileStation.CreateFolder", "create"))
cp_idx = summarised.index(("SYNO.Docker.Project", "POST:create"))
bs_idx = summarised.index(("SYNO.Docker.Project", "build_stream"))
assert cf_idx < cp_idx < bs_idx
# Verify JSON-encoding of CreateFolder params
cf_params = next(p for api, m, p in calls if api == "SYNO.FileStation.CreateFolder")
assert cf_params["folder_path"] == '"/docker"'
assert cf_params["name"] == '"newapp"'
assert cf_params["force_parent"] == "true"
# Verify JSON-encoding of Docker.Project/create params
cp_params = next(
p for api, m, p in calls if api == "SYNO.Docker.Project" and m == "POST:create"
)
assert cp_params["name"] == '"newapp"'
assert cp_params["share_path"] == '"/docker/newapp"'
assert cp_params["enable_service_portal"] == "false"
assert cp_params["service_portal_port"] == 0
@pytest.mark.asyncio
async def test_create_project_explicit_share_path():
"""Caller-supplied share_path overrides the derived default."""
client, calls = make_create_project_client()
tools = make_projects_tools(client)
result = await tools["create_project"](
"newapp",
SIMPLE_COMPOSE,
share_path="/projects/custom/newapp",
confirmed=True,
)
assert "created and started successfully" in result
cf_params = next(p for api, m, p in calls if api == "SYNO.FileStation.CreateFolder")
assert cf_params["folder_path"] == '"/projects/custom"'
assert cf_params["name"] == '"newapp"'
cp_params = next(
p for api, m, p in calls if api == "SYNO.Docker.Project" and m == "POST:create"
)
assert cp_params["share_path"] == '"/projects/custom/newapp"'
@pytest.mark.asyncio
async def test_create_project_error_2100_surfaces_hint():
"""DSM error 2100 on Project/create returns a clear 'target folder' message."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_create_project_client(
create_project_raises=SynologyError("Folder issue", code=2100),
)
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "2100" in result
assert "target folder" in result.lower()
# build_stream must NOT have been called after a failed Project/create
client.trigger_build_stream.assert_not_called()
@pytest.mark.asyncio
async def test_create_project_build_stream_failure_keeps_registration():
"""If build_stream fails AFTER successful Project/create, the user is told the
project is registered-but-not-started and pointed at redeploy_project."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_create_project_client(
build_stream_raises=SynologyError("transport error", code=0),
)
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "registered but was not started" in result
assert "redeploy_project" in result
assert "created and started successfully" not in result
# ──────────────────────────────────────────────────────────────────────
# delete_project
# ──────────────────────────────────────────────────────────────────────
def make_delete_project_client(
*,
project: dict | None = None,
delete_raises: Exception | None = None,
):
"""Stateful mock client for delete_project tests.
- `project`: the project dict returned by Project/list. None → no
project registered (simulates the "not found" case).
- `delete_raises`: optional exception raised when Project/delete is
called (used to simulate DSM refusing to delete a running project).
"""
client = AsyncMock()
calls: list[tuple[str, str, dict]] = []
project_deleted = False
async def mock_request(api, method, version=None, params=None, **kwargs):
nonlocal project_deleted
calls.append((api, method, dict(params or {})))
if api == "SYNO.Docker.Project" and method == "list":
if project is None or project_deleted:
return {}
return {project["id"]: project}
if api == "SYNO.Docker.Project" and method == "delete":
if delete_raises:
raise delete_raises
project_deleted = True
return {}
return {}
client.request.side_effect = mock_request
return client, calls
SAMPLE_PROJECT_RUNNING = {
"id": "uuid-abc",
"name": "myapp",
"status": "RUNNING",
"path": "/volume1/docker/myapp",
"share_path": "/docker/myapp",
"containerIds": ["c1"],
"services": [],
}
SAMPLE_PROJECT_STOPPED = {
**SAMPLE_PROJECT_RUNNING,
"status": "STOPPED",
"containerIds": [],
}
@pytest.mark.asyncio
async def test_delete_project_preview_only():
"""confirmed=False: no Project/delete call; preview shows UUID and warns about folder."""
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
tools = make_projects_tools(client)
result = await tools["delete_project"]("myapp")
assert "confirmed=True" in result
assert "uuid-abc" in result
assert "myapp" in result
assert "/docker/myapp" in result
# No delete call
methods = [m for _, m, _ in calls]
assert "delete" not in methods
@pytest.mark.asyncio
async def test_delete_project_not_found():
"""If the project isn't registered, return a clear 'not found' message — no delete."""
client, calls = make_delete_project_client(project=None)
tools = make_projects_tools(client)
result = await tools["delete_project"]("ghost", confirmed=True)
assert "not found" in result
assert "ghost" in result
methods = [m for _, m, _ in calls]
assert "delete" not in methods
@pytest.mark.asyncio
async def test_delete_project_rejects_invalid_name():
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
tools = make_projects_tools(client)
result = await tools["delete_project"]("../escape", confirmed=True)
assert "invalid project name" in result.lower()
# Not even a list call
assert calls == []
@pytest.mark.asyncio
async def test_delete_project_happy_path():
"""confirmed=True with a stopped project: UUID is json.dumps'd; success message
mentions both 'deleted' and the surviving folder path."""
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
tools = make_projects_tools(client)
result = await tools["delete_project"]("myapp", confirmed=True)
assert "deleted" in result
assert "registration removed" in result
assert "/docker/myapp" in result
assert "NOT deleted" in result
delete_call = next(
(a, m, p) for a, m, p in calls if a == "SYNO.Docker.Project" and m == "delete"
)
_api, _method, params = delete_call
# The UUID must arrive JSON-encoded per the reverse-engineered DSM convention.
assert params["id"] == '"uuid-abc"'
@pytest.mark.asyncio
async def test_delete_project_running_blocked_connector_side():
"""Live test showed that DSM does NOT reject Project/delete on a running project —
it silently orphans the containers. The connector must therefore block the call
itself when the project is RUNNING, without ever calling client.request(delete)."""
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_RUNNING)
tools = make_projects_tools(client)
result = await tools["delete_project"]("myapp", confirmed=True)
assert "RUNNING" in result
assert "stop_project" in result
# The delete endpoint must NOT have been called — no orphaned containers.
delete_calls = [m for _, m, _ in calls if m == "delete"]
assert delete_calls == []
# ──────────────────────────────────────────────────────────────────────
# compose-up-after-build: recover a CREATED build: project (Issue: cached
# build leaves the project CREATED, implicit `up` skipped)
# ──────────────────────────────────────────────────────────────────────
def make_created_recovery_mock(
*,
initial_status: str = "CREATED",
post_build_status: str = "CREATED",
start_raises=None,
status_after_start: str = "RUNNING",
):
"""Stateful redeploy mock that simulates a cached build landing CREATED.
Before build_stream, /list returns ``initial_status``. After build_stream,
/list returns ``post_build_status`` until Project/start is called, then
``status_after_start``. If ``start_raises`` is set, the start call raises.
"""
client = AsyncMock()
calls = []
build_done = False
started = False
async def mock_request(api, method, **kwargs):
nonlocal started
calls.append((api, method))
if method == "start":
if start_raises:
raise start_raises
started = True
return {}
if method == "list":
if not build_done:
return project_list(initial_status)
return project_list(status_after_start if started else post_build_status)
return {}
async def mock_build_stream(project_id, **kwargs):
nonlocal build_done
calls.append(("SYNO.Docker.Project", "build_stream"))
build_done = True
return "" # clean, closed stream — outcome decided by the status read
client.request.side_effect = mock_request
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
return client, calls
@pytest.mark.asyncio
async def test_redeploy_compose_up_after_cached_build():
"""build_stream leaves the project CREATED → redeploy issues compose up
(Project/start) → status reads RUNNING → success."""
client, calls = make_created_recovery_mock(initial_status="RUNNING")
tools = make_projects_tools(client)
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "redeployed successfully" in result
assert "compose up" in result
methods = [m for _, m in calls]
assert "start" in methods
assert methods.index("build_stream") < methods.index("start")
@pytest.mark.asyncio
async def test_redeploy_recovers_from_created_input_status():
"""A project that is already stuck in CREATED can be redeployed (no longer
refused as 'unexpected status')."""
client, calls = make_created_recovery_mock(initial_status="CREATED")
tools = make_projects_tools(client)
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "unexpected status" not in result
assert "redeployed successfully" in result
methods = [m for _, m in calls]
assert "build_stream" in methods
assert "start" in methods
@pytest.mark.asyncio
async def test_redeploy_created_start_rejected_shows_hint():
"""If compose up (start) is rejected from CREATED (e.g. DSM 2104), redeploy
reports an incomplete redeploy with the cold-rebuild workaround."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_created_recovery_mock(
initial_status="RUNNING",
start_raises=SynologyError("DSM error code 2104", code=2104),
)
tools = make_projects_tools(client)
result = await tools["redeploy_project"]("myapp", confirmed=True)
assert "redeployed successfully" not in result
assert "redeploy incomplete" in result
assert "CREATED" in result
assert "2104" in result
assert "cold rebuild" in result
@pytest.mark.asyncio
async def test_create_compose_up_after_cached_build():
"""create_project: a cached build lands CREATED → compose up (start) →
RUNNING → created-and-started success."""
client, calls = make_create_project_client(
final_status="CREATED",
status_after_start="RUNNING",
)
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "created and started successfully" in result
methods = [m for _, m, _ in calls]
assert "start" in methods
assert methods.index("build_stream") < methods.index("start")
@pytest.mark.asyncio
async def test_create_created_start_rejected_shows_hint():
"""create_project: cached build CREATED + start rejected → registered-but-not
-running message with the cold-rebuild workaround."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_create_project_client(
final_status="CREATED",
start_raises=SynologyError("DSM error code 2104", code=2104),
)
tools = make_projects_tools(client)
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
assert "created and started successfully" not in result
assert "containers are not running" in result
assert "CREATED" in result
assert "cold rebuild" in result
# ──────────────────────────────────────────────────────────────────────
# start_project: STOPPED/CREATED → compose up, with graceful 2104 hint
# ──────────────────────────────────────────────────────────────────────
def make_start_project_mock(initial_status, *, start_raises=None, status_after_start="RUNNING"):
client = AsyncMock()
calls = []
started = False
async def mock_request(api, method, **kwargs):
nonlocal started
calls.append((api, method))
if method == "start":
if start_raises:
raise start_raises
started = True
return {}
if method == "list":
return project_list(status_after_start if started else initial_status)
return {}
client.request.side_effect = mock_request
return client, calls
@pytest.mark.asyncio
async def test_start_project_created_recovers():
"""start_project on a CREATED project issues compose up and reports RUNNING."""
client, calls = make_start_project_mock("CREATED")
tools = make_projects_tools(client)
result = await tools["start_project"]("myapp")
assert "started successfully" in result
assert "RUNNING" in result
assert "start" in [m for _, m in calls]
@pytest.mark.asyncio
async def test_start_project_created_rejected_shows_hint():
"""start_project rejected from CREATED (DSM 2104) → actionable recovery hint."""
from mcp_synology_container.dsm_client import SynologyError
client, calls = make_start_project_mock(
"CREATED", start_raises=SynologyError("DSM error code 2104", code=2104)
)
tools = make_projects_tools(client)
result = await tools["start_project"]("myapp")
assert "Error starting" in result
assert "CREATED" in result
assert "2104" in result
assert "redeploy_project" in result
@pytest.mark.asyncio
async def test_start_project_already_running_is_noop():
"""start_project on a RUNNING project returns 'already RUNNING' and does not
call Project/start."""
client, calls = make_start_project_mock("RUNNING")
tools = make_projects_tools(client)
result = await tools["start_project"]("myapp")
assert "already RUNNING" in result
assert "start" not in [m for _, m in calls]