- Remove TestPortainerClientDockerSocketFallback class - Update wrapper method tests to expect RuntimeError on missing endpoints - Remove all socket fallback related test cases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
698 lines
27 KiB
Python
698 lines
27 KiB
Python
"""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
|