"""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 @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 @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