Build and Push / build (release) Successful in 1m28s
- Dashboard domain with Quick Links CRUD + reorder endpoints - Dashboard widgets management endpoints - Database migrations for quick_links and dashboard_widgets tables - Static file controller for Organizr widgets - Default local user when OIDC is disabled - Domain-based architecture refactor (src/domains/, src/shared/) - Test suite updated for new structure (285 tests passing) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
782 lines
32 KiB
Python
782 lines
32 KiB
Python
"""Tests for infrastructure endpoints."""
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from unittest.mock import patch, AsyncMock, MagicMock
|
|
|
|
from src.main import app
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
"""Create a test client."""
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_portainer():
|
|
"""Create a mock Portainer client."""
|
|
mock = AsyncMock()
|
|
return mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_npm():
|
|
"""Create a mock NPM client."""
|
|
mock = AsyncMock()
|
|
return mock
|
|
|
|
|
|
class TestInfrastructureHealth:
|
|
"""Test /infrastructure/health endpoint."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_health_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Health endpoint should return 200."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.health_check.return_value = True
|
|
mock_portainer.get_stacks.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_npm.health_check.return_value = True
|
|
mock_npm.get_proxy_hosts.return_value = []
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/health")
|
|
assert response.status_code == 200
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_health_returns_connection_status(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Health endpoint should return connection status for both services."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.health_check.return_value = True
|
|
mock_portainer.get_stacks.return_value = [{"Id": 1}, {"Id": 2}]
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_npm.health_check.return_value = True
|
|
mock_npm.get_proxy_hosts.return_value = [{"id": 1}]
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/health")
|
|
data = response.json()
|
|
|
|
assert "portainer_connected" in data
|
|
assert "npm_connected" in data
|
|
assert "total_stacks" in data
|
|
assert "total_proxy_hosts" in data
|
|
assert data["portainer_connected"] is True
|
|
assert data["npm_connected"] is True
|
|
assert data["total_stacks"] == 2
|
|
assert data["total_proxy_hosts"] == 1
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_health_handles_disconnected_services(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Health should handle when services are disconnected."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.health_check.return_value = False
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_npm.health_check.return_value = False
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/health")
|
|
data = response.json()
|
|
|
|
assert data["portainer_connected"] is False
|
|
assert data["npm_connected"] is False
|
|
assert data["total_stacks"] == 0
|
|
assert data["total_proxy_hosts"] == 0
|
|
|
|
|
|
class TestInfrastructureServices:
|
|
"""Test /infrastructure/services endpoints."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_list_services_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""List services should return 200."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_npm.get_proxy_hosts.return_value = []
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/services")
|
|
assert response.status_code == 200
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_list_services_returns_stack_info(self, mock_get_npm, mock_get_portainer, client):
|
|
"""List services should return stack information."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "stack1", "Status": 1, "EndpointId": 1},
|
|
{"Id": 2, "Name": "stack2", "Status": 2, "EndpointId": 1}
|
|
]
|
|
mock_portainer.get_containers.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_npm.get_proxy_hosts.return_value = []
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/services")
|
|
data = response.json()
|
|
|
|
assert isinstance(data, list)
|
|
assert len(data) == 2
|
|
assert data[0]["name"] == "stack1"
|
|
assert data[1]["name"] == "stack2"
|
|
|
|
|
|
class TestInfrastructurePorts:
|
|
"""Test /infrastructure/ports endpoint."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_list_ports_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""List ports should return 200."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
|
|
mock_portainer.get_containers.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_npm.get_proxy_hosts.return_value = []
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/ports")
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestInfrastructureDomains:
|
|
"""Test /infrastructure/domains endpoint."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_list_domains_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""List domains should return 200."""
|
|
mock_portainer = AsyncMock()
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_npm.get_proxy_hosts.return_value = []
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/domains")
|
|
assert response.status_code == 200
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_list_domains_returns_domain_info(self, mock_get_npm, mock_get_portainer, client):
|
|
"""List domains should return domain information."""
|
|
mock_portainer = AsyncMock()
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_npm.get_proxy_hosts.return_value = [
|
|
{
|
|
"id": 1,
|
|
"domain_names": ["example.com", "www.example.com"],
|
|
"forward_host": "app",
|
|
"forward_port": 8080,
|
|
"ssl_certificate_id": 1
|
|
}
|
|
]
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/domains")
|
|
data = response.json()
|
|
|
|
assert isinstance(data, list)
|
|
# Each domain name should be a separate entry
|
|
assert len(data) >= 1
|
|
|
|
|
|
class TestInfrastructureContainers:
|
|
"""Test /infrastructure/containers endpoint."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_list_containers_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""List containers should return 200."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.list_containers.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/containers")
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestInfrastructureWidgetData:
|
|
"""Test /infrastructure/widget-data endpoint."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_widget_data_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Widget data should return 200."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.health_check.return_value = True
|
|
mock_portainer.get_stacks.return_value = []
|
|
mock_portainer.list_containers.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_npm.health_check.return_value = True
|
|
mock_npm.get_proxy_hosts.return_value = []
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/widget-data")
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestInfrastructureServiceGroups:
|
|
"""Test /infrastructure/service-groups endpoint."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_service_groups_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Service groups should return 200."""
|
|
mock_portainer = AsyncMock()
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/service-groups")
|
|
assert response.status_code == 200
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_service_groups_returns_group_data(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Service groups should return group data."""
|
|
mock_portainer = AsyncMock()
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/service-groups")
|
|
data = response.json()
|
|
|
|
# Should return a dict or list of service groups
|
|
assert isinstance(data, (dict, list))
|
|
|
|
|
|
class TestInfrastructureResources:
|
|
"""Test /infrastructure/resources endpoints."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_system_resources_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""System resources should return 200 (or 500 if Docker unavailable)."""
|
|
mock_portainer = AsyncMock()
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/resources/system")
|
|
# 500 is acceptable when Docker socket is not available in test environment
|
|
assert response.status_code in [200, 500]
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_container_resources_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Container resources should return 200."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.list_containers.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/resources/containers")
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestInfrastructureServiceStatus:
|
|
"""Test /infrastructure/services/{service}/status endpoint."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_service_status_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Service status should return 200."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "testservice", "Status": 1, "EndpointId": 1}
|
|
]
|
|
mock_portainer.get_containers.return_value = [
|
|
{"Names": ["/testservice_app_1"], "State": "running"}
|
|
]
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_npm.get_proxy_hosts.return_value = []
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/services/testservice/status")
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestInfrastructureContainerActions:
|
|
"""Test container action endpoints."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_get_container_logs_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Get container logs should return 200 (or error if Docker unavailable)."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.list_containers.return_value = [
|
|
{"Names": ["/testcontainer"], "Id": "abc123"}
|
|
]
|
|
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/containers/testcontainer/logs")
|
|
# 500 is acceptable when Docker socket is not available in test environment
|
|
assert response.status_code in [200, 404, 500]
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_get_single_container_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Get single container should return 200."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.inspect_container.return_value = {
|
|
"Id": "abc123",
|
|
"Name": "/testcontainer",
|
|
"State": {"Status": "running"}
|
|
}
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/containers/testcontainer")
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestInfrastructureGetService:
|
|
"""Test /infrastructure/services/{name} endpoint."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_get_service_returns_200(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Get service should return 200."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "testservice", "Status": 1, "EndpointId": 1}
|
|
]
|
|
mock_portainer.get_containers.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_npm.get_proxy_hosts.return_value = []
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/services/testservice")
|
|
assert response.status_code == 200
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_get_service_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Get service should return 404 for non-existent service."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/services/nonexistent")
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestInfrastructureDeleteContainer:
|
|
"""Test DELETE /infrastructure/containers/{container_id} endpoint."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_delete_container_returns_204(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Delete container should return 204 on success."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
|
|
mock_portainer.delete_container.return_value = True
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.delete("/infrastructure/containers/abc123")
|
|
assert response.status_code == 204
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_delete_container_with_force(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Delete container should pass force parameter."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
|
|
mock_portainer.delete_container.return_value = True
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.delete("/infrastructure/containers/abc123?force=true")
|
|
assert response.status_code == 204
|
|
mock_portainer.delete_container.assert_called_once_with(1, "abc123", force=True)
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_delete_container_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Delete container should return 404 if not found."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
|
|
mock_portainer.delete_container.side_effect = Exception("404 no such container")
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.delete("/infrastructure/containers/nonexistent")
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestInfrastructureStackCompose:
|
|
"""Test /infrastructure/stacks/{stackId}/compose endpoints."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_get_stack_compose_returns_yaml(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Get stack compose should return YAML content."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "mystack", "EndpointId": 1}
|
|
]
|
|
mock_portainer.get_stack_file.return_value = "version: '3'\nservices:\n web:\n image: nginx"
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/stacks/mystack/compose")
|
|
assert response.status_code == 200
|
|
assert "text/yaml" in response.headers.get("content-type", "")
|
|
assert "version:" in response.text
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_get_stack_compose_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Get stack compose should return 404 if stack not found."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/stacks/nonexistent/compose")
|
|
assert response.status_code == 404
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_put_stack_compose_returns_204(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Update stack compose should return 204 on success."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "mystack", "EndpointId": 1}
|
|
]
|
|
mock_portainer.update_stack.return_value = {"Id": 1}
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.put(
|
|
"/infrastructure/stacks/mystack/compose",
|
|
content="version: '3'\nservices:\n web:\n image: nginx:latest",
|
|
headers={"Content-Type": "text/yaml"}
|
|
)
|
|
assert response.status_code == 204
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_put_stack_compose_returns_400_for_empty(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Update stack compose should return 400 for empty content."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "mystack", "EndpointId": 1}
|
|
]
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.put(
|
|
"/infrastructure/stacks/mystack/compose",
|
|
content="",
|
|
headers={"Content-Type": "text/yaml"}
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_put_stack_compose_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Update stack compose should return 404 if stack not found."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.put(
|
|
"/infrastructure/stacks/nonexistent/compose",
|
|
content="version: '3'",
|
|
headers={"Content-Type": "text/yaml"}
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestInfrastructureStackEnv:
|
|
"""Test /infrastructure/stacks/{stackId}/env endpoints."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_get_stack_env_returns_dict(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Get stack env should return environment variables as dict."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "mystack", "EndpointId": 1}
|
|
]
|
|
mock_portainer.get_stack.return_value = {
|
|
"Id": 1,
|
|
"Name": "mystack",
|
|
"Env": [
|
|
{"name": "DB_HOST", "value": "localhost"},
|
|
{"name": "DB_PORT", "value": "5432"}
|
|
]
|
|
}
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/stacks/mystack/env")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data == {"DB_HOST": "localhost", "DB_PORT": "5432"}
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_get_stack_env_returns_empty_dict(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Get stack env should return empty dict if no env vars."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "mystack", "EndpointId": 1}
|
|
]
|
|
mock_portainer.get_stack.return_value = {"Id": 1, "Name": "mystack", "Env": []}
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/stacks/mystack/env")
|
|
assert response.status_code == 200
|
|
assert response.json() == {}
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_get_stack_env_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Get stack env should return 404 if stack not found."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.get("/infrastructure/stacks/nonexistent/env")
|
|
assert response.status_code == 404
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_put_stack_env_returns_204(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Update stack env should return 204 on success."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "mystack", "EndpointId": 1}
|
|
]
|
|
mock_portainer.update_stack_env.return_value = {"Id": 1}
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.put(
|
|
"/infrastructure/stacks/mystack/env",
|
|
json={"DB_HOST": "newhost", "DB_PORT": "5433"}
|
|
)
|
|
assert response.status_code == 204
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_put_stack_env_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Update stack env should return 404 if stack not found."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.put(
|
|
"/infrastructure/stacks/nonexistent/env",
|
|
json={"KEY": "value"}
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestInfrastructureStackDeploy:
|
|
"""Test /infrastructure/stacks/{stackId}/deploy endpoint."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_deploy_stack_returns_202(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Deploy stack should return 202 Accepted."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "mystack", "EndpointId": 1}
|
|
]
|
|
mock_portainer.redeploy_stack.return_value = {"Id": 1}
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.post("/infrastructure/stacks/mystack/deploy")
|
|
assert response.status_code == 202
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_deploy_stack_does_not_pull_images(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Deploy stack should not pull images."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "mystack", "EndpointId": 1}
|
|
]
|
|
mock_portainer.redeploy_stack.return_value = {"Id": 1}
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.post("/infrastructure/stacks/mystack/deploy")
|
|
assert response.status_code == 202
|
|
mock_portainer.redeploy_stack.assert_called_once_with(
|
|
stack_id=1,
|
|
endpoint_id=1,
|
|
pull_image=False
|
|
)
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_deploy_stack_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Deploy stack should return 404 if stack not found."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.post("/infrastructure/stacks/nonexistent/deploy")
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestInfrastructureStackRebuild:
|
|
"""Test /infrastructure/stacks/{stackId}/rebuild endpoint."""
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_rebuild_stack_returns_202(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Rebuild stack should return 202 Accepted."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "mystack", "EndpointId": 1}
|
|
]
|
|
mock_portainer.redeploy_stack.return_value = {"Id": 1}
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.post("/infrastructure/stacks/mystack/rebuild")
|
|
assert response.status_code == 202
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_rebuild_stack_pulls_images(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Rebuild stack should pull images."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "mystack", "EndpointId": 1}
|
|
]
|
|
mock_portainer.redeploy_stack.return_value = {"Id": 1}
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.post("/infrastructure/stacks/mystack/rebuild")
|
|
assert response.status_code == 202
|
|
mock_portainer.redeploy_stack.assert_called_once_with(
|
|
stack_id=1,
|
|
endpoint_id=1,
|
|
pull_image=True
|
|
)
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_rebuild_stack_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Rebuild stack should return 404 if stack not found."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = []
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.post("/infrastructure/stacks/nonexistent/rebuild")
|
|
assert response.status_code == 404
|
|
|
|
@patch("src.domains.infrastructure.controller.get_portainer_client")
|
|
@patch("src.domains.infrastructure.controller.get_npm_client")
|
|
def test_rebuild_stack_case_insensitive(self, mock_get_npm, mock_get_portainer, client):
|
|
"""Rebuild stack should match stack name case-insensitively."""
|
|
mock_portainer = AsyncMock()
|
|
mock_portainer.get_stacks.return_value = [
|
|
{"Id": 1, "Name": "MyStack", "EndpointId": 1}
|
|
]
|
|
mock_portainer.redeploy_stack.return_value = {"Id": 1}
|
|
mock_get_portainer.return_value = mock_portainer
|
|
|
|
mock_npm = AsyncMock()
|
|
mock_get_npm.return_value = mock_npm
|
|
|
|
response = client.post("/infrastructure/stacks/mystack/rebuild")
|
|
assert response.status_code == 202
|