"""Tests for modules/containers.py.""" from unittest.mock import AsyncMock import pytest def make_mock_mcp(): tools: dict = {} class MockMCP: def tool(self): def decorator(fn): tools[fn.__name__] = fn return fn return decorator return MockMCP(), tools def make_config(): from mcp_synology_container.config import AppConfig, ConnectionConfig return AppConfig( schema_version=1, connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True), ) SAMPLE_CONTAINERS_DATA = { "containers": [ { "name": "myapp_web", "status": "running", "image": "nginx:alpine", "project_name": "myapp", }, { "name": "myapp_db", "status": "running", "image": "postgres:15", "project_name": "myapp", }, { "name": "other_svc", "status": "stopped", "image": "redis:7", "project_name": "other", }, ] } # Container data where DSM returns hash-prefixed names HASH_PREFIXED_CONTAINERS_DATA = { "containers": [ { "name": "f93cb8b504f7_jenkins", "status": "running", "image": "jenkins/jenkins:lts", "project_name": "frostiq", }, ] } SAMPLE_LOGS_DATA = { "logs": [ { "created": "2025-01-01T10:00:00Z", "stream": "stdout", "text": "Server started", "docid": "1", }, { "created": "2025-01-01T10:00:01Z", "stream": "stderr", "text": "Warning: deprecated option", "docid": "2", }, ], "total": 2, } @pytest.mark.asyncio async def test_list_containers_all(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = SAMPLE_CONTAINERS_DATA mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["list_containers"]() assert "myapp_web" in result assert "myapp_db" in result assert "other_svc" in result @pytest.mark.asyncio async def test_list_containers_filtered_by_project(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = SAMPLE_CONTAINERS_DATA mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["list_containers"](project_name="myapp") assert "myapp_web" in result assert "myapp_db" in result assert "other_svc" not in result @pytest.mark.asyncio async def test_list_containers_empty(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = {"containers": []} mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["list_containers"]() assert "No containers found" in result @pytest.mark.asyncio async def test_get_container_logs(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = SAMPLE_LOGS_DATA mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["get_container_logs"]("myapp_web", tail=50) assert "myapp_web" in result assert "Server started" in result assert "Warning: deprecated option" in result @pytest.mark.asyncio async def test_get_container_logs_with_keyword(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = SAMPLE_LOGS_DATA mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) await tools["get_container_logs"]("myapp_web", tail=100, keyword="error") call_params = client.request.call_args[1]["params"] assert call_params["keyword"] == "error" @pytest.mark.asyncio async def test_exec_in_container_requires_confirmation(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["exec_in_container"]("myapp_web", "ls /app", confirmed=False) assert "confirmed=True" in result client.request.assert_not_called() @pytest.mark.asyncio async def test_exec_in_container_confirmed(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = {"output": "file1.py\nfile2.py", "exit_code": 0} mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["exec_in_container"]("myapp_web", "ls /app", confirmed=True) assert "file1.py" in result assert "Exit code: 0" in result # ────────────────────────────────────────────────────────────────────────────── # container_stats # ────────────────────────────────────────────────────────────────────────────── # Realistic stats snapshot for a container named "glance" SAMPLE_STATS = { "ee220111cff2": { "id": "ee220111cff2", "name": "/glance", "cpu_stats": { "cpu_usage": { "total_usage": 1_022_653_189, "percpu_usage": [286394951, 245078386, 304613157, 186566695], }, "system_cpu_usage": 990_015_100_000_000, "online_cpus": 4, }, "precpu_stats": { "cpu_usage": {"total_usage": 1_000_000_000}, "system_cpu_usage": 989_000_000_000_000, }, "memory_stats": { "usage": 21_127_168, "limit": 4_079_349_760, }, "networks": { "eth0": {"rx_bytes": 876, "tx_bytes": 0}, "eth1": {"rx_bytes": 1024, "tx_bytes": 512}, }, "blkio_stats": { "io_service_bytes_recursive": [ {"op": "Read", "value": 4096}, {"op": "Write", "value": 8192}, {"op": "Total", "value": 12288}, ] }, } } @pytest.mark.asyncio async def test_container_stats_found(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = SAMPLE_STATS mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["container_stats"]("glance") assert "glance" in result assert "CPU" in result assert "%" in result assert "Memory" in result assert "Net I/O" in result assert "Block I/O" in result # ────────────────────────────────────────────────────────────────────────────── # delete_container # ────────────────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_delete_container_preview(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["delete_container"]("myapp_web", confirmed=False) assert "Preview" in result assert "myapp_web" in result client.request.assert_not_called() @pytest.mark.asyncio async def test_delete_container_not_found(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = None mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["delete_container"]("nonexistent", confirmed=True) assert "not found" in result @pytest.mark.asyncio async def test_delete_container_running_blocked(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = { "details": {"State": {"Running": True, "Status": "running"}}, "profile": {"image": "nginx:latest"}, } mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["delete_container"]("myapp_web", confirmed=True) assert "running" in result.lower() assert "Cannot delete" in result @pytest.mark.asyncio async def test_delete_container_stopped_confirmed(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = { "details": {"State": {"Running": False, "Status": "exited"}}, "profile": {"image": "nginx:latest"}, } mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["delete_container"]("myapp_web", confirmed=True) assert "Deleted" in result assert "myapp_web" in result @pytest.mark.asyncio async def test_delete_container_sends_required_params(): """SYNO.Docker.Container/delete must include name (json-encoded), force=false, and preserve_profile=false — without them DSM returns error 114.""" from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = { "details": {"State": {"Running": False, "Status": "exited"}}, "profile": {"image": "nginx:latest"}, } mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) await tools["delete_container"]("myapp_web", confirmed=True) # Find the delete call (second call — first is Container/get) delete_calls = [c for c in client.request.call_args_list if c.args[1] == "delete"] assert len(delete_calls) == 1 params = delete_calls[0].kwargs.get("params") or {} assert params["name"] == '"myapp_web"' assert params["force"] == "false" assert params["preserve_profile"] == "false" @pytest.mark.asyncio async def test_container_stats_cpu_calculation(): """CPU% is computed via the standard Docker formula.""" from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = SAMPLE_STATS mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["container_stats"]("glance") # cpu_delta = 1_022_653_189 - 1_000_000_000 = 22_653_189 # system_delta = 990_015_100_000_000 - 989_000_000_000_000 = 1_015_100_000_000 # cpu_pct = (22_653_189 / 1_015_100_000_000) * 4 * 100 ≈ 0.0089 % assert "0.00" in result or "%" in result # value near zero but formatted @pytest.mark.asyncio async def test_container_stats_memory_human_readable(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = SAMPLE_STATS mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["container_stats"]("glance") # 21_127_168 bytes ≈ 20 MiB; limit 4_079_349_760 ≈ 3.8 GiB assert "MiB" in result or "GiB" in result @pytest.mark.asyncio async def test_container_stats_name_with_slash(): """Container name matching strips leading slash from DSM response.""" from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = SAMPLE_STATS mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) # Should match even if called without the leading slash result = await tools["container_stats"]("glance") assert "not found" not in result.lower() @pytest.mark.asyncio async def test_container_stats_not_found(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = SAMPLE_STATS mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["container_stats"]("nonexistent") assert "not found" in result.lower() assert "glance" in result # shows available containers @pytest.mark.asyncio async def test_container_stats_api_error(): from mcp_synology_container.dsm_client import SynologyError from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.side_effect = SynologyError("API error", code=102) mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["container_stats"]("glance") assert "Error" in result # ────────────────────────────────────────────────────────────────────────────── # Bug 1: hash-prefix stripping # ────────────────────────────────────────────────────────────────────────────── def test_strip_hash_prefix_strips_prefix(): from mcp_synology_container.modules.containers import _strip_hash_prefix assert _strip_hash_prefix("f93cb8b504f7_jenkins") == "jenkins" def test_strip_hash_prefix_no_prefix(): from mcp_synology_container.modules.containers import _strip_hash_prefix assert _strip_hash_prefix("jenkins") == "jenkins" def test_strip_hash_prefix_leading_slash(): from mcp_synology_container.modules.containers import _strip_hash_prefix assert _strip_hash_prefix("/jenkins") == "jenkins" def test_strip_hash_prefix_slash_with_hash(): from mcp_synology_container.modules.containers import _strip_hash_prefix assert _strip_hash_prefix("/f93cb8b504f7_jenkins") == "jenkins" @pytest.mark.asyncio async def test_list_containers_strips_hash_prefix(): """list_containers must display the clean name without the hash prefix.""" from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = HASH_PREFIXED_CONTAINERS_DATA mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["list_containers"]() assert "jenkins" in result assert "f93cb8b504f7_jenkins" not in result @pytest.mark.asyncio async def test_container_stats_strips_hash_prefix(): """container_stats must match even when DSM returns a hash-prefixed name.""" from mcp_synology_container.modules.containers import register_containers stats_with_hash = { "abc123": { "name": "/f93cb8b504f7_jenkins", "cpu_stats": { "cpu_usage": {"total_usage": 500_000, "percpu_usage": [500_000]}, "system_cpu_usage": 100_000_000_000, "online_cpus": 1, }, "precpu_stats": { "cpu_usage": {"total_usage": 0}, "system_cpu_usage": 0, }, "memory_stats": {"usage": 1024, "limit": 2048}, "networks": {}, "blkio_stats": {"io_service_bytes_recursive": []}, } } client = AsyncMock() client.request.return_value = stats_with_hash mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) # User passes clean name; must still match the hash-prefixed entry result = await tools["container_stats"]("jenkins") assert "not found" not in result.lower() assert "CPU" in result DSM_CONTAINER_RESPONSE = { "details": { "State": { "Status": "running", "Running": True, "StartedAt": "2025-01-01T10:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z", "ExitCode": 0, }, "NetworkSettings": { "Networks": { "bridge": {"IPAddress": "172.17.0.2"}, } }, "Mounts": [ { "Type": "bind", "Source": "/volume1/docker/jenkins", "Destination": "/var/jenkins_home", "RW": True, } ], }, "profile": { "image": "jenkins/jenkins:2.558-jdk21", "port_bindings": [ {"host_port": 8080, "container_port": 8080, "type": "tcp"}, {"host_port": 50000, "container_port": 50000, "type": "tcp"}, ], }, } @pytest.mark.asyncio async def test_get_container_status_uses_clean_name(): """get_container_status strips hash prefix and calls get with clean name.""" from mcp_synology_container.modules.containers import register_containers async def mock_request(api, method, **kwargs): if api == "SYNO.Docker.Container" and method == "get": assert kwargs["params"]["name"] == "jenkins" return DSM_CONTAINER_RESPONSE return {} client = AsyncMock() client.request.side_effect = mock_request mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) # User passes hash-prefixed name → stripped before get call result = await tools["get_container_status"]("f93cb8b504f7_jenkins") assert "running" in result assert "f93cb8b504f7" not in result @pytest.mark.asyncio async def test_get_container_status_details_profile_structure(): """get_container_status reads status from details.State, image from profile.""" from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = DSM_CONTAINER_RESPONSE mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["get_container_status"]("jenkins") assert "running" in result assert "jenkins/jenkins:2.558-jdk21" in result assert "True" in result # Running field @pytest.mark.asyncio async def test_get_container_status_shows_ip(): """get_container_status shows IP address from NetworkSettings.""" from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = DSM_CONTAINER_RESPONSE mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["get_container_status"]("jenkins") assert "172.17.0.2" in result @pytest.mark.asyncio async def test_get_container_status_shows_ports(): """get_container_status shows port bindings from profile.""" from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = DSM_CONTAINER_RESPONSE mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["get_container_status"]("jenkins") assert "8080" in result assert "50000" in result @pytest.mark.asyncio async def test_get_container_status_shows_mounts(): """get_container_status shows mount paths from details.Mounts.""" from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = DSM_CONTAINER_RESPONSE mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["get_container_status"]("jenkins") assert "/volume1/docker/jenkins" in result assert "/var/jenkins_home" in result # ────────────────────────────────────────────────────────────────────────────── # inspect_container # ────────────────────────────────────────────────────────────────────────────── # Live-captured-style response (homeassistant) — proves that # details.Mounts[].Source carries the full /volume1/... path while # profile.volume_bindings[].host_volume_file is share-relative. INSPECT_RESPONSE = { "details": { "Config": { "Image": "homeassistant/home-assistant:stable", "Entrypoint": ["/init"], "Cmd": None, "Env": ["TZ=Europe/Berlin"], }, "HostConfig": { "RestartPolicy": {"Name": "always", "MaximumRetryCount": 0}, "NetworkMode": "frostiq_net", "Privileged": False, "CapAdd": None, "CapDrop": None, "Binds": ["/volume1/docker/homeassistant:/config:rw"], }, "Mounts": [ { "Type": "bind", "Source": "/volume1/docker/homeassistant", "Destination": "/config", "RW": True, } ], "NetworkSettings": { "Networks": { "frostiq_net": {"IPAddress": "172.18.0.5"}, } }, "RestartCount": 0, "State": {"Status": "running", "Running": True}, }, "profile": { "image": "homeassistant/home-assistant:stable", "name": "homeassistant", "enable_restart_policy": True, "network_mode": "frostiq_net", "use_host_network": False, "port_bindings": [{"container_port": 8123, "host_port": 8123, "type": "tcp"}], # Share-relative — must NOT be the path the tool reports. "volume_bindings": [ { "host_volume_file": "/docker/homeassistant", "mount_point": "/config", "type": "rw", "is_directory": True, } ], "env_variables": [ {"key": "TZ", "value": "Europe/Berlin"}, {"key": "LANG", "value": "C.UTF-8"}, ], "labels": {"io.hass.type": "core", "io.hass.version": "2026.2.3"}, "links": [], "cmd": "", "cmd_v2": "", "privileged": False, "CapAdd": None, "CapDrop": None, }, } @pytest.mark.asyncio async def test_inspect_container_uses_full_host_path(): """inspect_container must report /volume1/docker/... (full) — not /docker/... (share-relative) — for volume mounts. The compose-rebuild workflow depends on the full host path.""" from mcp_synology_container.modules.containers import register_containers async def mock_request(api, method, **kwargs): if api == "SYNO.Docker.Container" and method == "list": return {"containers": [{"name": "homeassistant"}]} if api == "SYNO.Docker.Container" and method == "get": return INSPECT_RESPONSE return {} client = AsyncMock() client.request.side_effect = mock_request mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["inspect_container"]("homeassistant") assert "/volume1/docker/homeassistant" in result # The share-relative shortcut must not appear as a mount source. assert " /docker/homeassistant " not in result # not as standalone path assert "→ /config" in result @pytest.mark.asyncio async def test_inspect_container_shows_core_fields(): from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = INSPECT_RESPONSE mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["inspect_container"]("homeassistant") # Header assert "Container: homeassistant" in result assert "homeassistant/home-assistant:stable" in result assert "running" in result # Restart policy assert "always" in result # Network assert "frostiq_net" in result assert "172.18.0.5" in result # Ports assert "8123" in result # Env assert "TZ=Europe/Berlin" in result # Labels assert "io.hass.version=2026.2.3" in result # Entrypoint assert "/init" in result @pytest.mark.asyncio async def test_inspect_container_calls_get_with_json_name(): """inspect_container must send name= as a JSON-encoded string (DSM Container/get is documented to accept both but json.dumps keeps the convention shared with start/stop/restart/delete).""" from mcp_synology_container.modules.containers import register_containers seen: dict[str, object] = {} async def mock_request(api, method, **kwargs): if api == "SYNO.Docker.Container" and method == "list": return {"containers": [{"name": "homeassistant"}]} if api == "SYNO.Docker.Container" and method == "get": seen["params"] = kwargs.get("params") return INSPECT_RESPONSE return {} client = AsyncMock() client.request.side_effect = mock_request mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) await tools["inspect_container"]("homeassistant") assert seen["params"] == {"name": '"homeassistant"'} @pytest.mark.asyncio async def test_inspect_container_resolves_hash_prefix(): """If DSM stores the container as 'abcdef012345_homeassistant', a user request for 'homeassistant' must resolve to the prefixed name and the displayed header must show the clean name.""" from mcp_synology_container.modules.containers import register_containers async def mock_request(api, method, **kwargs): if api == "SYNO.Docker.Container" and method == "list": return {"containers": [{"name": "abcdef012345_homeassistant"}]} if api == "SYNO.Docker.Container" and method == "get": assert kwargs["params"]["name"] == '"abcdef012345_homeassistant"' return INSPECT_RESPONSE return {} client = AsyncMock() client.request.side_effect = mock_request mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["inspect_container"]("homeassistant") assert "Container: homeassistant" in result assert "abcdef012345" not in result @pytest.mark.asyncio async def test_inspect_container_not_found(): from mcp_synology_container.modules.containers import register_containers async def mock_request(api, method, **kwargs): if api == "SYNO.Docker.Container" and method == "list": return {"containers": []} if api == "SYNO.Docker.Container" and method == "get": return None return {} client = AsyncMock() client.request.side_effect = mock_request mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["inspect_container"]("ghost") assert "not found" in result @pytest.mark.asyncio async def test_inspect_container_api_error(): from mcp_synology_container.dsm_client import SynologyError from mcp_synology_container.modules.containers import register_containers async def mock_request(api, method, **kwargs): if api == "SYNO.Docker.Container" and method == "list": return {"containers": [{"name": "homeassistant"}]} if api == "SYNO.Docker.Container" and method == "get": raise SynologyError("API error", code=102) return {} client = AsyncMock() client.request.side_effect = mock_request mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["inspect_container"]("homeassistant") assert "Error" in result assert "homeassistant" in result @pytest.mark.asyncio async def test_get_container_logs_resolves_hash_prefix(): """get_container_logs resolves 'jenkins' to 'f93cb8b504f7_jenkins' for DSM call.""" from mcp_synology_container.modules.containers import register_containers async def mock_request(api, method, **kwargs): if api == "SYNO.Docker.Container" and method == "list": return HASH_PREFIXED_CONTAINERS_DATA if api == "SYNO.Docker.Container.Log" and method == "get": assert kwargs["params"]["name"] == "f93cb8b504f7_jenkins" return { "logs": [{"created": "2025-01-01", "stream": "stdout", "text": "started"}], "total": 1, } return {} client = AsyncMock() client.request.side_effect = mock_request mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["get_container_logs"]("jenkins") assert "started" in result assert "f93cb8b504f7" not in result @pytest.mark.asyncio async def test_exec_in_container_resolves_hash_prefix(): """exec_in_container resolves 'jenkins' to 'f93cb8b504f7_jenkins' for DSM call.""" from mcp_synology_container.modules.containers import register_containers async def mock_request(api, method, **kwargs): if api == "SYNO.Docker.Container" and method == "list": return HASH_PREFIXED_CONTAINERS_DATA if api == "SYNO.Docker.Container" and method == "exec": assert kwargs["params"]["name"] == "f93cb8b504f7_jenkins" return {"output": "ok", "exit_code": 0} return {} client = AsyncMock() client.request.side_effect = mock_request mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["exec_in_container"]("jenkins", "echo ok", confirmed=True) assert "ok" in result @pytest.mark.asyncio async def test_container_stats_no_precpu_graceful(): """When precpu_stats has no system_cpu_usage (first poll), CPU% = 0.""" from mcp_synology_container.modules.containers import register_containers stats_no_pre = { "abc123": { "name": "/myapp", "cpu_stats": { "cpu_usage": {"total_usage": 500_000, "percpu_usage": [500_000]}, "system_cpu_usage": 100_000_000_000, "online_cpus": 1, }, "precpu_stats": { "cpu_usage": {"total_usage": 0}, # system_cpu_usage absent → system_delta = 0 }, "memory_stats": {"usage": 1024, "limit": 2048}, "networks": {}, "blkio_stats": {"io_service_bytes_recursive": []}, } } client = AsyncMock() client.request.return_value = stats_no_pre mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools["container_stats"]("myapp") assert "0.00%" in result # graceful fallback to 0 # ────────────────────────────────────────────────────────────────────────────── # Lifecycle: start / stop / restart # ────────────────────────────────────────────────────────────────────────────── # Tools that don't have a confirmation gate _NO_CONFIRM_LIFECYCLE = [ ("start_container", "start", "Started"), ] # Tools that require confirmed=True _CONFIRM_LIFECYCLE = [ ("stop_container", "stop", "Stopped", "stop"), ("restart_container", "restart", "Restarted", "restart"), ] @pytest.mark.parametrize("tool_name, dsm_method, success_word", _NO_CONFIRM_LIFECYCLE) @pytest.mark.asyncio async def test_lifecycle_no_confirm_calls_dsm(tool_name, dsm_method, success_word): """start_container calls DSM directly with json-encoded name.""" from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = {} mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools[tool_name]("myapp_web") # Find the matching lifecycle call (a list() may also occur via _resolve_container_name) calls = [c for c in client.request.call_args_list if c.args[1] == dsm_method] assert len(calls) == 1 call = calls[0] assert call.args[0] == "SYNO.Docker.Container" assert call.kwargs["version"] == 1 assert call.kwargs["params"] == {"name": '"myapp_web"'} assert success_word in result assert "myapp_web" in result @pytest.mark.parametrize("tool_name, dsm_method, success_word, _action", _CONFIRM_LIFECYCLE) @pytest.mark.asyncio async def test_lifecycle_confirmed_calls_dsm(tool_name, dsm_method, success_word, _action): """stop/restart call DSM with confirmed=True using json-encoded name.""" from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.return_value = {} mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools[tool_name]("myapp_web", confirmed=True) calls = [c for c in client.request.call_args_list if c.args[1] == dsm_method] assert len(calls) == 1 call = calls[0] assert call.args[0] == "SYNO.Docker.Container" assert call.kwargs["version"] == 1 assert call.kwargs["params"] == {"name": '"myapp_web"'} assert success_word in result assert "myapp_web" in result @pytest.mark.parametrize("tool_name, _dsm_method, _success_word, action", _CONFIRM_LIFECYCLE) @pytest.mark.asyncio async def test_lifecycle_preview_without_confirmation( tool_name, _dsm_method, _success_word, action ): """stop/restart without confirmed=True must NOT call DSM.""" from mcp_synology_container.modules.containers import register_containers client = AsyncMock() mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools[tool_name]("myapp_web") assert "Preview" in result assert action in result assert "myapp_web" in result assert "confirmed=True" in result client.request.assert_not_called() @pytest.mark.parametrize( "tool_name, dsm_method, kwargs", [ ("start_container", "start", {}), ("stop_container", "stop", {"confirmed": True}), ("restart_container", "restart", {"confirmed": True}), ], ) @pytest.mark.asyncio async def test_lifecycle_resolves_hash_prefix(tool_name, dsm_method, kwargs): """All lifecycle tools resolve 'jenkins' to 'f93cb8b504f7_jenkins' for the DSM call.""" from mcp_synology_container.modules.containers import register_containers async def mock_request(api, method, **kw): if api == "SYNO.Docker.Container" and method == "list": return HASH_PREFIXED_CONTAINERS_DATA if api == "SYNO.Docker.Container" and method == dsm_method: assert kw["params"]["name"] == '"f93cb8b504f7_jenkins"' assert kw["version"] == 1 return {} return {} client = AsyncMock() client.request.side_effect = mock_request mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) # User passes the clean name; resolved to the hash-prefixed name for DSM, # but the display name in the success message must be hash-stripped. result = await tools[tool_name]("jenkins", **kwargs) assert "jenkins" in result assert "f93cb8b504f7" not in result @pytest.mark.parametrize( "tool_name, kwargs, error_verb", [ ("start_container", {}, "starting"), ("stop_container", {"confirmed": True}, "stopping"), ("restart_container", {"confirmed": True}, "restarting"), ], ) @pytest.mark.asyncio async def test_lifecycle_api_error(tool_name, kwargs, error_verb): """API errors during lifecycle ops return a human-readable error message.""" from mcp_synology_container.dsm_client import SynologyError from mcp_synology_container.modules.containers import register_containers client = AsyncMock() client.request.side_effect = SynologyError("API error", code=102) mcp, tools = make_mock_mcp() register_containers(mcp, make_config(), client) result = await tools[tool_name]("myapp_web", **kwargs) assert "Error" in result assert error_verb in result assert "myapp_web" in result