"""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 # ────────────────────────────────────────────────────────────────────── # 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 == []