"""Tests for Portainer client.""" import pytest from unittest.mock import patch, AsyncMock, MagicMock import httpx from src.clients.portainer_client import PortainerClient, get_portainer_client class TestPortainerClientInit: """Test PortainerClient initialization.""" @patch("src.clients.portainer_client.settings") def test_uses_settings_defaults(self, mock_settings): """Client should use settings for defaults.""" mock_settings.portainer_url = "http://portainer:9000" mock_settings.portainer_api_key = "test_key" client = PortainerClient() assert client.base_url == "http://portainer:9000" assert client.api_key == "test_key" def test_accepts_custom_url_and_key(self): """Client should accept custom URL and API key.""" client = PortainerClient( base_url="http://custom:9000", api_key="custom_key" ) assert client.base_url == "http://custom:9000" assert client.api_key == "custom_key" def test_strips_trailing_slash_from_url(self): """Client should strip trailing slash from URL.""" client = PortainerClient( base_url="http://custom:9000/", api_key="key" ) assert client.base_url == "http://custom:9000" @patch("src.clients.portainer_client.logger") @patch("src.clients.portainer_client.settings") def test_warns_when_api_key_missing(self, mock_settings, mock_logger): """Client should warn when API key is not configured.""" mock_settings.portainer_url = "http://portainer:9000" mock_settings.portainer_api_key = "" PortainerClient() mock_logger.warning.assert_called_once() class TestPortainerClientHeaders: """Test header generation.""" def test_get_headers_includes_api_key(self): """Headers should include X-API-Key.""" client = PortainerClient( base_url="http://portainer:9000", api_key="my_api_key" ) headers = client._get_headers() assert headers["X-API-Key"] == "my_api_key" assert headers["Content-Type"] == "application/json" class TestPortainerClientHealthCheck: """Test health check functionality.""" @pytest.mark.asyncio async def test_health_check_returns_true_on_200(self): """Health check should return True when Portainer responds 200.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.health_check() assert result is True @pytest.mark.asyncio async def test_health_check_returns_false_on_error(self): """Health check should return False on connection error.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_client.get.side_effect = Exception("Connection refused") mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.health_check() assert result is False @pytest.mark.asyncio async def test_health_check_returns_false_on_non_200(self): """Health check should return False on non-200 status.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 401 mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.health_check() assert result is False class TestPortainerClientEndpoints: """Test endpoint retrieval.""" @pytest.mark.asyncio async def test_get_endpoints_returns_list(self): """get_endpoints should return list of endpoints.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") endpoints = [ {"Id": 1, "Name": "local"}, {"Id": 2, "Name": "remote"} ] with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = endpoints mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.get_endpoints() assert result == endpoints assert len(result) == 2 class TestPortainerClientStacks: """Test stack operations.""" @pytest.mark.asyncio async def test_get_stacks_returns_list(self): """get_stacks should return list of stacks.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") stacks = [ {"Id": 1, "Name": "stack1", "Status": 1}, {"Id": 2, "Name": "stack2", "Status": 1} ] with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = stacks mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.get_stacks() assert result == stacks @pytest.mark.asyncio async def test_get_stacks_with_endpoint_filter(self): """get_stacks should filter by endpoint_id when provided.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = [] mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client await client.get_stacks(endpoint_id=3) # Verify params include endpoint_id call_args = mock_client.get.call_args assert call_args[1]["params"]["endpointId"] == 3 @pytest.mark.asyncio async def test_get_stack_returns_single_stack(self): """get_stack should return a single stack.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") stack = {"Id": 1, "Name": "mystack", "Status": 1} with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = stack mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.get_stack(1) assert result == stack @pytest.mark.asyncio async def test_create_stack_posts_correct_data(self): """create_stack should POST with correct payload.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"Id": 1, "Name": "newstack"} mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client await client.create_stack("newstack", "version: '3'\nservices:", 1) call_args = mock_client.post.call_args assert call_args[1]["json"]["name"] == "newstack" assert "stackFileContent" in call_args[1]["json"] @pytest.mark.asyncio async def test_update_stack_puts_correct_data(self): """update_stack should PUT with correct payload.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"Id": 1, "Name": "stack"} mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.put.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client await client.update_stack(1, "version: '3'", 1, prune=True, pull_image=True) call_args = mock_client.put.call_args assert call_args[1]["json"]["prune"] is True assert call_args[1]["json"]["pullImage"] is True @pytest.mark.asyncio async def test_delete_stack_returns_true(self): """delete_stack should return True on success.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 204 mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.delete.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.delete_stack(1, 1) assert result is True class TestPortainerClientContainers: """Test container operations.""" @pytest.mark.asyncio async def test_get_containers_returns_list(self): """get_containers should return list of containers.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") containers = [ {"Id": "abc123", "Names": ["/container1"], "State": "running"}, {"Id": "def456", "Names": ["/container2"], "State": "exited"} ] with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = containers mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.get_containers(1) assert result == containers @pytest.mark.asyncio async def test_get_container_returns_details(self): """get_container should return container details.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") container = {"Id": "abc123", "Name": "/container1", "State": {"Status": "running"}} with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = container mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.get_container(1, "abc123") assert result == container @pytest.mark.asyncio async def test_stop_container_returns_true(self): """stop_container should return True on success.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 204 mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.stop_container(1, "abc123") assert result is True @pytest.mark.asyncio async def test_start_container_returns_true(self): """start_container should return True on success.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 204 mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.start_container(1, "abc123") assert result is True class TestPortainerClientWrapperMethods: """Test convenience wrapper methods.""" @pytest.mark.asyncio async def test_list_containers_uses_portainer(self): """list_containers should use Portainer API.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") containers = [{"Id": "abc123", "Names": ["/test"]}] with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints: mock_endpoints.return_value = [{"Id": 1}] with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_containers: mock_containers.return_value = containers result = await client.list_containers() assert result == containers mock_endpoints.assert_called_once() mock_containers.assert_called_once() @pytest.mark.asyncio async def test_list_containers_raises_when_no_endpoints(self): """list_containers should raise RuntimeError when no endpoints available.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints: mock_endpoints.return_value = [] with pytest.raises(RuntimeError, match="No Portainer endpoints available"): await client.list_containers() @pytest.mark.asyncio async def test_inspect_container_uses_portainer(self): """inspect_container should use Portainer API.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") container_list = [{"Id": "abc123", "Names": ["/mycontainer"]}] container_detail = {"Id": "abc123", "Name": "/mycontainer", "State": {"Status": "running"}} with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints: mock_endpoints.return_value = [{"Id": 1}] with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_list: mock_list.return_value = container_list with patch.object(client, "get_container", new_callable=AsyncMock) as mock_detail: mock_detail.return_value = container_detail result = await client.inspect_container("mycontainer") assert result == container_detail @pytest.mark.asyncio async def test_inspect_container_returns_none_when_not_found(self): """inspect_container should return None if container not found.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints: mock_endpoints.return_value = [{"Id": 1}] with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_list: mock_list.return_value = [] # No containers result = await client.inspect_container("missing_container") assert result is None @pytest.mark.asyncio async def test_inspect_container_raises_when_no_endpoints(self): """inspect_container should raise RuntimeError when no endpoints available.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints: mock_endpoints.return_value = [] with pytest.raises(RuntimeError, match="No Portainer endpoints available"): await client.inspect_container("container") class TestPortainerClientStackFile: """Test stack file operations.""" @pytest.mark.asyncio async def test_get_stack_file_returns_content(self): """get_stack_file should return compose YAML content.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") compose_content = "version: '3'\nservices:\n web:\n image: nginx" with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"StackFileContent": compose_content} mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.get_stack_file(1) assert result == compose_content @pytest.mark.asyncio async def test_get_stack_file_returns_empty_string_if_missing(self): """get_stack_file should return empty string if content missing.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {} mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.get_stack_file(1) assert result == "" class TestPortainerClientRedeployStack: """Test stack redeploy operations.""" @pytest.mark.asyncio async def test_redeploy_stack_without_pull(self): """redeploy_stack should redeploy without pulling images by default.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch.object(client, "get_stack_file", new_callable=AsyncMock) as mock_file: mock_file.return_value = "version: '3'" with patch.object(client, "get_stack", new_callable=AsyncMock) as mock_stack: mock_stack.return_value = {"Id": 1, "Env": [{"name": "KEY", "value": "val"}]} with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"Id": 1} mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.put.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.redeploy_stack(1, 1, pull_image=False) call_args = mock_client.put.call_args assert call_args[1]["json"]["pullImage"] is False assert result == {"Id": 1} @pytest.mark.asyncio async def test_redeploy_stack_with_pull(self): """redeploy_stack should pull images when requested.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch.object(client, "get_stack_file", new_callable=AsyncMock) as mock_file: mock_file.return_value = "version: '3'" with patch.object(client, "get_stack", new_callable=AsyncMock) as mock_stack: mock_stack.return_value = {"Id": 1, "Env": []} with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"Id": 1} mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.put.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client await client.redeploy_stack(1, 1, pull_image=True) call_args = mock_client.put.call_args assert call_args[1]["json"]["pullImage"] is True class TestPortainerClientUpdateStackEnv: """Test stack environment variable updates.""" @pytest.mark.asyncio async def test_update_stack_env_sends_env_vars(self): """update_stack_env should update environment variables.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") env_vars = [{"name": "DB_HOST", "value": "localhost"}] with patch.object(client, "get_stack_file", new_callable=AsyncMock) as mock_file: mock_file.return_value = "version: '3'" with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"Id": 1} mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.put.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client await client.update_stack_env(1, 1, env_vars) call_args = mock_client.put.call_args assert call_args[1]["json"]["env"] == env_vars class TestPortainerClientDeleteContainer: """Test container deletion.""" @pytest.mark.asyncio async def test_delete_container_returns_true(self): """delete_container should return True on success.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 204 mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.delete.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.delete_container(1, "abc123") assert result is True @pytest.mark.asyncio async def test_delete_container_with_force(self): """delete_container should pass force parameter.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 204 mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.delete.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client await client.delete_container(1, "abc123", force=True) call_args = mock_client.delete.call_args assert call_args[1]["params"]["force"] == "true" class TestPortainerClientRestartContainer: """Test container restart.""" @pytest.mark.asyncio async def test_restart_container_returns_true(self): """restart_container should return True on success.""" client = PortainerClient(base_url="http://portainer:9000", api_key="key") with patch("httpx.AsyncClient") as mock_client_class: mock_response = MagicMock() mock_response.status_code = 204 mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None mock_client_class.return_value = mock_client result = await client.restart_container(1, "abc123") assert result is True mock_client.post.assert_called_once() class TestPortainerClientSingleton: """Test singleton pattern.""" def test_returns_same_instance(self): """get_portainer_client should return singleton.""" # Reset singleton import src.clients.portainer_client as module module._portainer_client = None client1 = get_portainer_client() client2 = get_portainer_client() assert client1 is client2