wrolpi/controller/test/test_services_api.py
Roland 5a11e23a56
controller: expose kind/group in the services API response
The React ControllerPage partitions services on `service.group`, but
ServiceStatusResponse did not declare the new kind/group fields, so
Pydantic silently stripped them from /api/services and the React UI
rendered one flat, ungrouped table. The fallback UI was unaffected
because it is server-rendered without the response model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:44:59 -06:00

187 lines
7.8 KiB
Python

"""
Integration tests for Controller services API endpoints.
"""
from unittest import mock
import pytest
class TestServicesListEndpoint:
"""Tests for /api/services endpoint."""
def test_services_list_returns_200(self, test_client):
"""Services list endpoint should return 200 OK."""
response = test_client.get("/api/services")
assert response.status_code == 200
def test_services_list_returns_list_or_error(self, test_client):
"""Services list should return list of services or error object."""
response = test_client.get("/api/services")
data = response.json()
# Should be a list (when docker can manage) or error dict
assert isinstance(data, (list, dict))
def test_services_list_includes_group_and_kind(self, test_client):
"""The React UI partitions on `group`; the response model must not
strip it (or `kind`) from the systemd status dicts."""
statuses = [
{"name": "wrolpi-api", "status": "running", "enabled": True,
"kind": "persistent", "group": "core"},
{"name": "wrolpi-repair", "status": "stopped", "enabled": False,
"kind": "task", "group": "optional"},
]
with mock.patch("controller.api.services.is_docker_mode", return_value=False):
with mock.patch(
"controller.api.services.get_all_services_status",
return_value=statuses,
):
response = test_client.get("/api/services")
assert response.status_code == 200
data = {s["name"]: s for s in response.json()}
assert data["wrolpi-api"]["group"] == "core"
assert data["wrolpi-api"]["kind"] == "persistent"
assert data["wrolpi-repair"]["group"] == "optional"
assert data["wrolpi-repair"]["kind"] == "task"
class TestServicesListDockerMode:
"""Tests for /api/services in Docker mode."""
def test_returns_containers_when_docker_available(self, test_client_docker_mode):
"""Should return container status when Docker is available."""
mock_containers = [
{"name": "api", "status": "running", "container_name": "wrolpi-api-1"}
]
with mock.patch(
"controller.lib.docker_services.can_manage_containers",
return_value=True
):
with mock.patch(
"controller.lib.docker_services.get_all_containers_status",
return_value=mock_containers
):
# Also patch in the main module
with mock.patch(
"controller.main.can_manage_containers",
return_value=True
):
with mock.patch(
"controller.main.get_all_containers_status",
return_value=mock_containers
):
with mock.patch(
"controller.api.services.can_manage_containers",
return_value=True
):
with mock.patch(
"controller.api.services.get_all_containers_status",
return_value=mock_containers
):
response = test_client_docker_mode.get("/api/services")
assert response.status_code == 200
class TestServiceStatusEndpoint:
"""Tests for /api/services/{name} endpoint."""
def test_service_status_returns_status(self, test_client):
"""Should return service status."""
with mock.patch(
"controller.api.services.get_service_status",
return_value={
"name": "wrolpi-api",
"status": "running",
"systemd_name": "wrolpi-api.service",
}
):
response = test_client.get("/api/services/wrolpi-api")
assert response.status_code == 200
data = response.json()
assert data["name"] == "wrolpi-api"
def test_unknown_service_returns_404(self, test_client):
"""Should return 404 for unknown service."""
with mock.patch(
"controller.api.services.get_service_status",
return_value={"error": "Unknown service: nonexistent"}
):
response = test_client.get("/api/services/nonexistent")
assert response.status_code == 404
class TestServiceActionEndpoints:
"""Tests for service action endpoints (start, stop, restart, enable, disable)."""
@pytest.mark.parametrize("action,endpoint,mock_func", [
("start", "/api/services/wrolpi-api/start", "start_service"),
("stop", "/api/services/wrolpi-api/stop", "stop_service"),
("restart", "/api/services/wrolpi-api/restart", "restart_service"),
("enable", "/api/services/wrolpi-api/enable", "enable_service"),
("disable", "/api/services/wrolpi-api/disable", "disable_service"),
])
def test_action_success(self, test_client, action, endpoint, mock_func):
"""Should perform service action successfully."""
with mock.patch(
f"controller.api.services.{mock_func}",
return_value={"success": True, "service": "wrolpi-api", "action": action}
):
response = test_client.post(endpoint)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["action"] == action
def test_start_failure_returns_500(self, test_client):
"""Should return 500 on start failure."""
with mock.patch(
"controller.api.services.start_service",
return_value={"success": False, "error": "Failed to start"}
):
response = test_client.post("/api/services/wrolpi-api/start")
assert response.status_code == 500
@pytest.mark.parametrize("endpoint", [
"/api/services/wrolpi-api/enable",
"/api/services/wrolpi-api/disable",
])
def test_enable_disable_not_available_in_docker(self, test_client_docker_mode, endpoint):
"""Should return 501 in Docker mode for enable/disable."""
response = test_client_docker_mode.post(endpoint)
assert response.status_code == 501
class TestServiceLogsEndpoint:
"""Tests for /api/services/{name}/logs endpoint."""
def test_logs_returns_200(self, test_client):
"""Should return logs successfully."""
with mock.patch(
"controller.api.services.get_service_logs",
return_value={"service": "wrolpi-api", "lines": 100, "logs": "test logs"}
):
response = test_client.get("/api/services/wrolpi-api/logs")
assert response.status_code == 200
data = response.json()
assert "logs" in data
def test_logs_accepts_lines_parameter(self, test_client):
"""Should accept lines query parameter."""
with mock.patch(
"controller.api.services.get_service_logs",
return_value={"service": "wrolpi-api", "lines": 50, "logs": "logs"}
):
response = test_client.get("/api/services/wrolpi-api/logs?lines=50")
assert response.status_code == 200
def test_logs_accepts_since_parameter(self, test_client):
"""Should accept since query parameter."""
with mock.patch(
"controller.api.services.get_service_logs",
return_value={"service": "wrolpi-api", "lines": 100, "since": "1h", "logs": "logs"}
):
response = test_client.get("/api/services/wrolpi-api/logs?since=1h")
assert response.status_code == 200
# OpenAPI endpoint-presence tests are consolidated in test_api.py::TestOpenAPI.