"""Tests for modules/compose.py.""" import pytest from unittest.mock import AsyncMock, patch import yaml 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), compose_base_path="/volume1/docker", ) SAMPLE_COMPOSE = """ services: web: image: nginx:1.24 ports: - "80:80" environment: - APP_ENV=production - LOG_LEVEL=info db: image: postgres:15 environment: POSTGRES_DB: mydb POSTGRES_USER: admin """ @pytest.mark.asyncio async def test_read_compose(): from mcp_synology_container.modules.compose import register_compose client = AsyncMock() # Simulate FileStation.Info success for the first filename client.request.return_value = {} client.download_text.return_value = SAMPLE_COMPOSE mcp, tools = make_mock_mcp() register_compose(mcp, make_config(), client) result = await tools["read_compose"]("myapp") assert "nginx:1.24" in result assert "postgres:15" in result @pytest.mark.asyncio async def test_read_compose_not_found(): from mcp_synology_container.modules.compose import register_compose client = AsyncMock() # Simulate all FileStation.Info calls failing from mcp_synology_container.dsm_client import SynologyError client.request.side_effect = SynologyError("not found", code=408) mcp, tools = make_mock_mcp() register_compose(mcp, make_config(), client) result = await tools["read_compose"]("nonexistent") assert "No compose file found" in result @pytest.mark.asyncio async def test_update_image_tag_requires_confirmation(): from mcp_synology_container.modules.compose import register_compose client = AsyncMock() client.request.return_value = {} client.download_text.return_value = SAMPLE_COMPOSE mcp, tools = make_mock_mcp() register_compose(mcp, make_config(), client) result = await tools["update_image_tag"]("myapp", "web", "1.25", confirmed=False) assert "confirmed=True" in result assert "nginx:1.24" in result assert "nginx:1.25" in result client.upload_text.assert_not_called() @pytest.mark.asyncio async def test_update_image_tag_confirmed(): from mcp_synology_container.modules.compose import register_compose client = AsyncMock() client.request.return_value = {} client.download_text.return_value = SAMPLE_COMPOSE mcp, tools = make_mock_mcp() register_compose(mcp, make_config(), client) result = await tools["update_image_tag"]("myapp", "web", "1.25", confirmed=True) assert "nginx:1.24 → nginx:1.25" in result assert "redeploy_project" in result client.upload_text.assert_called_once() # Verify the uploaded content has the new tag uploaded_content = client.upload_text.call_args[0][2] parsed = yaml.safe_load(uploaded_content) assert parsed["services"]["web"]["image"] == "nginx:1.25" @pytest.mark.asyncio async def test_update_image_tag_service_not_found(): from mcp_synology_container.modules.compose import register_compose client = AsyncMock() client.request.return_value = {} client.download_text.return_value = SAMPLE_COMPOSE mcp, tools = make_mock_mcp() register_compose(mcp, make_config(), client) result = await tools["update_image_tag"]("myapp", "nonexistent", "1.25", confirmed=True) assert "not found" in result assert "web" in result # should list available services @pytest.mark.asyncio async def test_update_env_var_new_var_list_format(): from mcp_synology_container.modules.compose import register_compose client = AsyncMock() client.request.return_value = {} client.download_text.return_value = SAMPLE_COMPOSE mcp, tools = make_mock_mcp() register_compose(mcp, make_config(), client) result = await tools["update_env_var"]("myapp", "web", "NEW_VAR", "value123", confirmed=True) assert "NEW_VAR=value123" in result uploaded_content = client.upload_text.call_args[0][2] parsed = yaml.safe_load(uploaded_content) env = parsed["services"]["web"]["environment"] assert any("NEW_VAR=value123" in str(e) for e in env) @pytest.mark.asyncio async def test_update_env_var_update_existing_list(): from mcp_synology_container.modules.compose import register_compose client = AsyncMock() client.request.return_value = {} client.download_text.return_value = SAMPLE_COMPOSE mcp, tools = make_mock_mcp() register_compose(mcp, make_config(), client) result = await tools["update_env_var"]("myapp", "web", "LOG_LEVEL", "debug", confirmed=True) assert "LOG_LEVEL=debug" in result uploaded_content = client.upload_text.call_args[0][2] parsed = yaml.safe_load(uploaded_content) env = parsed["services"]["web"]["environment"] assert "LOG_LEVEL=debug" in env assert "LOG_LEVEL=info" not in env @pytest.mark.asyncio async def test_update_env_var_dict_format(): from mcp_synology_container.modules.compose import register_compose client = AsyncMock() client.request.return_value = {} client.download_text.return_value = SAMPLE_COMPOSE mcp, tools = make_mock_mcp() register_compose(mcp, make_config(), client) # db service has dict-format environment result = await tools["update_env_var"]("myapp", "db", "POSTGRES_DB", "newdb", confirmed=True) assert "POSTGRES_DB=newdb" in result uploaded_content = client.upload_text.call_args[0][2] parsed = yaml.safe_load(uploaded_content) assert parsed["services"]["db"]["environment"]["POSTGRES_DB"] == "newdb" @pytest.mark.asyncio async def test_update_compose_invalid_yaml(): from mcp_synology_container.modules.compose import register_compose client = AsyncMock() mcp, tools = make_mock_mcp() register_compose(mcp, make_config(), client) result = await tools["update_compose"]("myapp", "not: valid: yaml: {{{{", confirmed=True) assert "Invalid YAML" in result @pytest.mark.asyncio async def test_update_compose_missing_services_key(): from mcp_synology_container.modules.compose import register_compose client = AsyncMock() mcp, tools = make_mock_mcp() register_compose(mcp, make_config(), client) result = await tools["update_compose"]("myapp", "version: '3'\n", confirmed=True) assert "services" in result @pytest.mark.asyncio async def test_update_compose_requires_confirmation(): from mcp_synology_container.modules.compose import register_compose client = AsyncMock() client.request.return_value = {} mcp, tools = make_mock_mcp() register_compose(mcp, make_config(), client) result = await tools["update_compose"]("myapp", SAMPLE_COMPOSE, confirmed=False) assert "confirmed=True" in result client.upload_text.assert_not_called()