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