"""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, pull_raises=None): """Create a stateful client mock for redeploy tests. Returns (client, calls_list). After ``start`` 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 = [] start_called = False async def mock_request(api, method, **kwargs): nonlocal start_called calls.append((api, method)) if method == "start": start_called = True if method == "stop" and stop_raises: raise stop_raises if method == "pull" and pull_raises: raise pull_raises if method == "list": return project_list("RUNNING") if start_called else project_list(initial_status) return {} client.request.side_effect = mock_request return client, calls @pytest.mark.asyncio async def test_redeploy_running_project(): """RUNNING project: stop then start; polls 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 "start" in methods assert methods.index("stop") < methods.index("start") @pytest.mark.asyncio async def test_redeploy_stopped_project_starts_directly(): """STOPPED project: skip stop, just start; 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 "start" in methods assert "STOPPED" in result or "starting directly" in result.lower() @pytest.mark.asyncio async def test_redeploy_build_failed_project(): """BUILD_FAILED project: stop → pull → start; polls 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 "pull" in methods assert "start" in methods assert methods.index("stop") < methods.index("pull") assert methods.index("pull") < methods.index("start") @pytest.mark.asyncio async def test_redeploy_build_failed_stop_error_nonfatal(): """BUILD_FAILED: stop failure is non-fatal and must not abort the redeploy.""" from mcp_synology_container.dsm_client import SynologyError client, _ = make_stateful_redeploy_mock( "BUILD_FAILED", stop_raises=SynologyError("already stopped", code=2101), pull_raises=None, # pull succeeds ) 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 @pytest.mark.asyncio async def test_redeploy_build_failed_pull_error_aborts(): """BUILD_FAILED: pull failure must abort redeploy with a clear message.""" from mcp_synology_container.dsm_client import SynologyError client, calls = make_stateful_redeploy_mock( "BUILD_FAILED", stop_raises=None, pull_raises=SynologyError("image not found", 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 "Aborted" in result or "pull failed" in result.lower() assert "compose.yaml" in result or "update_image_tag" in result # start must NOT have been called after a pull failure methods = [m for _, m in calls] assert "start" not in methods @pytest.mark.asyncio async def test_redeploy_poll_timeout(): """If project never reaches RUNNING after start, a warning is emitted.""" client = AsyncMock() start_called = False async def mock_request(api, method, **kwargs): nonlocal start_called if method == "start": start_called = True if method == "list": # Before start: return RUNNING so initial status check picks a valid path. # After start: return STARTING to simulate a stuck container — triggers timeout. return project_list("STARTING") if start_called else project_list("RUNNING") return {} client.request.side_effect = mock_request 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._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 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