feat: v0.3.2 — delete_project tool
Closes the project lifecycle (create → start/stop/redeploy → delete). The tool calls SYNO.Docker.Project/delete with the UUID JSON-encoded as the `id` parameter (per DSM convention) and removes only the Container Manager registration — the project folder and compose file remain on the NAS. This mirrors DSM's own "Delete project" behaviour, not a bug; the success message states the folder was preserved so the user is not surprised. Safety: - Project-name validation runs before any I/O. - A `_find_project` pre-flight returns "not found" with a clear message rather than letting DSM reject an unknown UUID. - No automatic stop. If the project is RUNNING and DSM rejects the delete, the response tells the user to `stop_project` first rather than silently halting containers under the guise of a "delete" call. - Requires confirmed=True; preview shows name, UUID, status, full path, and share path so the user can verify before deleting. Tests cover preview-only, not-found, invalid-name, happy path (verifies the UUID is JSON-encoded in the delete call), and the running-project rejection path that surfaces the stop_project hint. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -774,3 +774,144 @@ async def test_create_project_build_stream_failure_keeps_registration():
|
||||
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_returns_stop_hint():
|
||||
"""DSM refusing to delete a running project produces a clean 'stop_project' hint
|
||||
rather than a raw error."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client, calls = make_delete_project_client(
|
||||
project=SAMPLE_PROJECT_RUNNING,
|
||||
delete_raises=SynologyError("Project is running", code=2103),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["delete_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "stop_project" in result
|
||||
assert "running" in result.lower()
|
||||
# No "deleted" success line
|
||||
assert "deleted (registration removed)" not in result
|
||||
|
||||
Reference in New Issue
Block a user