801dbe15dc
Adds `create_project` for registering a new Container Manager project
from a compose YAML string. Three-step flow that mirrors the DSM
"Create Project" wizard:
1. SYNO.FileStation.CreateFolder with force_parent=true (idempotent
— does not fail if the folder already exists, and creates missing
intermediate directories). Without this step, Docker.Project/create
fails with DSM error 2100.
2. SYNO.Docker.Project/create (form-encoded POST; JSON-encoded string
parameters per DSM convention) returns the new project UUID.
3. trigger_build_stream + _wait_for_project_running, reusing the
existing image-pull / start / poll machinery (including the
BUILD_FAILED early-exit from welle 2).
Safety:
- Project-name validation (Welle-1 regex) runs before any I/O.
- Compose content is YAML-parsed and must contain a top-level
`services` key before any side effects.
- A pre-flight list_projects check rejects duplicate names with a
clear message rather than leaving an orphaned folder on the NAS.
- share_path defaults to compose_base_path + project_name (e.g.
/volume1/docker + myapp → /docker/myapp); a caller-supplied value
overrides it.
- Requires confirmed=True; the preview shows the resolved share path
and the service count parsed from the compose content.
- DSM error 2100 surfaces as "target folder issue" with the attempted
path. A build_stream failure after a successful Project/create tells
the user the project is registered-but-not-started and points at
redeploy_project for recovery.
Tests cover preview-only, already-exists, happy path (with parameter
JSON-encoding assertions), explicit share_path, malformed YAML,
missing services key, invalid project name, error 2100, and
build_stream failure after registration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
777 lines
28 KiB
Python
777 lines
28 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
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# 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,
|
|
project_id: str = "uuid-new",
|
|
final_status: 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`)
|
|
- which API/method/version each call used
|
|
"""
|
|
client = AsyncMock()
|
|
calls: list[tuple[str, str, dict]] = []
|
|
project_created = False
|
|
|
|
async def mock_request(api, method, version=None, params=None, **kwargs):
|
|
calls.append((api, method, dict(params or {})))
|
|
if api == "SYNO.Docker.Project" and method == "list":
|
|
if project_created:
|
|
return {
|
|
project_id: {
|
|
"id": project_id,
|
|
"name": "newapp",
|
|
"status": final_status,
|
|
"path": "/volume1/docker/newapp",
|
|
"containerIds": [],
|
|
"services": [],
|
|
}
|
|
}
|
|
return existing_projects or {}
|
|
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):
|
|
calls.append(("SYNO.Docker.Project", "build_stream", {"id": pid}))
|
|
if build_stream_raises:
|
|
raise build_stream_raises
|
|
|
|
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)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
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)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
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)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
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)
|
|
|
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
|
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
|