"""Tests for modules/projects.py.""" import pytest from unittest.mock import AsyncMock, MagicMock 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.modules.projects import register_projects from mcp_synology_container.config import AppConfig, ConnectionConfig 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.modules.projects import register_projects from mcp_synology_container.config import AppConfig, ConnectionConfig 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.modules.projects import register_projects from mcp_synology_container.config import AppConfig, ConnectionConfig 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.modules.projects import register_projects from mcp_synology_container.config import AppConfig, ConnectionConfig 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": [], } } @pytest.mark.asyncio async def test_redeploy_running_project(): """RUNNING project: stop then start (2 steps).""" client = AsyncMock() calls = [] async def mock_request(api, method, **kwargs): calls.append((api, method)) return project_list("RUNNING") if method == "list" else {} client.request.side_effect = mock_request tools = make_projects_tools(client) 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 # stop must come before start assert methods.index("stop") < methods.index("start") @pytest.mark.asyncio async def test_redeploy_stopped_project_starts_directly(): """STOPPED project: skip stop, just start.""" client = AsyncMock() calls = [] async def mock_request(api, method, **kwargs): calls.append((api, method)) return project_list("STOPPED") if method == "list" else {} client.request.side_effect = mock_request tools = make_projects_tools(client) 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: force-stop (non-fatal), then start.""" client = AsyncMock() calls = [] async def mock_request(api, method, **kwargs): calls.append((api, method)) return project_list("BUILD_FAILED") if method == "list" else {} client.request.side_effect = mock_request tools = make_projects_tools(client) 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_build_failed_stop_error_nonfatal(): """BUILD_FAILED: stop failure must not abort the redeploy.""" from mcp_synology_container.dsm_client import SynologyError client = AsyncMock() async def mock_request(api, method, **kwargs): if method == "list": return project_list("BUILD_FAILED") if method == "stop": raise SynologyError("already stopped", code=2101) return {} # start succeeds client.request.side_effect = mock_request tools = make_projects_tools(client) result = await tools["redeploy_project"]("myapp", confirmed=True) assert "redeployed successfully" 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