feat(server): API proxy routes for services and nodes (TDD)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
"""Tests for server API proxy routes — nodes and services.
|
||||
|
||||
Mocks httpx.AsyncClient to avoid real network calls to agents.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
os.environ.setdefault("CONFIG_PATH", os.path.join(
|
||||
os.path.dirname(__file__), "..", "config.json"
|
||||
))
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_config():
|
||||
"""Reset the config module cache between tests."""
|
||||
import server.config as cfg
|
||||
cfg._config = None
|
||||
yield
|
||||
cfg._config = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from server.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mock_response(json_data, status_code=200):
|
||||
"""Build a MagicMock httpx.Response with sync .json() method."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = json_data
|
||||
resp.raise_for_status = MagicMock()
|
||||
return resp
|
||||
|
||||
|
||||
def _make_mock_client(**side_effects):
|
||||
"""Create a mock httpx.AsyncClient context manager.
|
||||
|
||||
*side_effects* maps method names ('get', 'post') to lists of
|
||||
return values or Exception instances for side_effect.
|
||||
"""
|
||||
mock_instance = AsyncMock()
|
||||
for method, values in side_effects.items():
|
||||
getattr(mock_instance, method).side_effect = values
|
||||
return mock_instance
|
||||
|
||||
|
||||
def _patch_httpx(module_path, mock_instance):
|
||||
"""Return a context-manager patch for httpx.AsyncClient in *module_path*."""
|
||||
patcher = patch(f"{module_path}.httpx.AsyncClient")
|
||||
MockClient = patcher.start()
|
||||
MockClient.return_value.__aenter__ = AsyncMock(return_value=mock_instance)
|
||||
MockClient.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
return patcher
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetNodes:
|
||||
|
||||
def test_returns_all_three_nodes(self, client):
|
||||
"""GET /api/nodes returns a list of 3 nodes with health status."""
|
||||
mock = _make_mock_client(get=[
|
||||
_mock_response({"status": "healthy", "hostname": "hf-pdocker-01", "containers_total": 10}),
|
||||
_mock_response({"status": "healthy", "hostname": "hf-pdocker-02", "containers_total": 5}),
|
||||
_mock_response({"status": "healthy", "hostname": "bart", "containers_total": 8}),
|
||||
])
|
||||
|
||||
p = _patch_httpx("server.routes.nodes", mock)
|
||||
try:
|
||||
resp = client.get("/api/nodes")
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 3
|
||||
names = [n["name"] for n in data]
|
||||
assert "hf-pdocker-01" in names
|
||||
assert "hf-pdocker-02" in names
|
||||
assert "bart" in names
|
||||
|
||||
def test_node_contains_health_fields(self, client):
|
||||
"""Each node has name, host, healthy, and containers_total."""
|
||||
mock = _make_mock_client(get=[
|
||||
_mock_response({"status": "healthy", "hostname": "hf-pdocker-01", "containers_total": 10}),
|
||||
_mock_response({"status": "healthy", "hostname": "hf-pdocker-02", "containers_total": 5}),
|
||||
_mock_response({"status": "healthy", "hostname": "bart", "containers_total": 8}),
|
||||
])
|
||||
|
||||
p = _patch_httpx("server.routes.nodes", mock)
|
||||
try:
|
||||
resp = client.get("/api/nodes")
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
data = resp.json()
|
||||
node = data[0]
|
||||
assert "name" in node
|
||||
assert "host" in node
|
||||
assert "healthy" in node
|
||||
assert "containers_total" in node
|
||||
|
||||
def test_unhealthy_node_on_agent_error(self, client):
|
||||
"""If an agent is unreachable, the node shows healthy=False."""
|
||||
mock = _make_mock_client(get=[
|
||||
_mock_response({"status": "healthy", "hostname": "hf-pdocker-01", "containers_total": 10}),
|
||||
Exception("Connection refused"),
|
||||
_mock_response({"status": "healthy", "hostname": "bart", "containers_total": 8}),
|
||||
])
|
||||
|
||||
p = _patch_httpx("server.routes.nodes", mock)
|
||||
try:
|
||||
resp = client.get("/api/nodes")
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
data = resp.json()
|
||||
assert resp.status_code == 200
|
||||
unhealthy = [n for n in data if n["name"] == "hf-pdocker-02"]
|
||||
assert len(unhealthy) == 1
|
||||
assert unhealthy[0]["healthy"] is False
|
||||
assert unhealthy[0]["error"] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/services
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetServices:
|
||||
|
||||
def test_returns_containers_from_all_nodes(self, client):
|
||||
"""GET /api/services aggregates containers from all nodes, adding node field."""
|
||||
containers_node1 = [
|
||||
{"id": "abc1", "name": "app1", "status": "running",
|
||||
"image": "nginx:latest", "created": "2026-03-05T00:00:00Z",
|
||||
"uptime": "1h", "is_swarm": False, "swarm_service": None},
|
||||
]
|
||||
containers_node2 = [
|
||||
{"id": "abc2", "name": "app2", "status": "running",
|
||||
"image": "redis:latest", "created": "2026-03-05T00:00:00Z",
|
||||
"uptime": "2h", "is_swarm": False, "swarm_service": None},
|
||||
]
|
||||
containers_node3 = [
|
||||
{"id": "abc3", "name": "app3", "status": "exited",
|
||||
"image": "postgres:15", "created": "2026-03-05T00:00:00Z",
|
||||
"uptime": None, "is_swarm": False, "swarm_service": None},
|
||||
]
|
||||
|
||||
mock = _make_mock_client(get=[
|
||||
_mock_response(containers_node1),
|
||||
_mock_response(containers_node2),
|
||||
_mock_response(containers_node3),
|
||||
])
|
||||
|
||||
p = _patch_httpx("server.routes.services", mock)
|
||||
try:
|
||||
resp = client.get("/api/services")
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 3
|
||||
nodes = [c["node"] for c in data]
|
||||
assert "hf-pdocker-01" in nodes
|
||||
assert "hf-pdocker-02" in nodes
|
||||
assert "bart" in nodes
|
||||
|
||||
def test_skips_unreachable_node(self, client):
|
||||
"""If a node agent is down, its containers are omitted."""
|
||||
containers_node1 = [
|
||||
{"id": "abc1", "name": "app1", "status": "running",
|
||||
"image": "nginx:latest", "created": "2026-03-05T00:00:00Z",
|
||||
"uptime": "1h", "is_swarm": False, "swarm_service": None},
|
||||
]
|
||||
|
||||
mock = _make_mock_client(get=[
|
||||
_mock_response(containers_node1),
|
||||
Exception("Connection refused"),
|
||||
_mock_response([]),
|
||||
])
|
||||
|
||||
p = _patch_httpx("server.routes.services", mock)
|
||||
try:
|
||||
resp = client.get("/api/services")
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/services/{node}/{container_id}/start
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStartService:
|
||||
|
||||
def test_start_proxies_to_agent(self, client):
|
||||
"""POST start sends request to correct agent and returns result."""
|
||||
mock = _make_mock_client(post=[
|
||||
_mock_response({"success": True, "message": "Started container app1"}),
|
||||
])
|
||||
|
||||
p = _patch_httpx("server.routes.services", mock)
|
||||
try:
|
||||
resp = client.post("/api/services/hf-pdocker-01/abc123/start")
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
def test_start_invalid_node_returns_404(self, client):
|
||||
"""POST start with unknown node returns 404."""
|
||||
resp = client.post("/api/services/nonexistent-node/abc123/start")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/services/{node}/{container_id}/stop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStopService:
|
||||
|
||||
def test_stop_proxies_to_agent(self, client):
|
||||
"""POST stop sends request to correct agent."""
|
||||
mock = _make_mock_client(post=[
|
||||
_mock_response({"success": True, "message": "Stopped container app1"}),
|
||||
])
|
||||
|
||||
p = _patch_httpx("server.routes.services", mock)
|
||||
try:
|
||||
resp = client.post("/api/services/bart/abc123/stop")
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
def test_stop_invalid_node_returns_404(self, client):
|
||||
resp = client.post("/api/services/nonexistent-node/abc123/stop")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/services/{node}/{container_id}/restart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRestartService:
|
||||
|
||||
def test_restart_proxies_to_agent(self, client):
|
||||
mock = _make_mock_client(post=[
|
||||
_mock_response({"success": True, "message": "Restarted container app1"}),
|
||||
])
|
||||
|
||||
p = _patch_httpx("server.routes.services", mock)
|
||||
try:
|
||||
resp = client.post("/api/services/hf-pdocker-02/abc123/restart")
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
def test_restart_invalid_node_returns_404(self, client):
|
||||
resp = client.post("/api/services/nonexistent-node/abc123/restart")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/services/{node}/{container_id}/logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetServiceLogs:
|
||||
|
||||
def test_logs_proxies_to_agent(self, client):
|
||||
mock = _make_mock_client(get=[
|
||||
_mock_response({"container": "test-app", "logs": "log line 1\nlog line 2"}),
|
||||
])
|
||||
|
||||
p = _patch_httpx("server.routes.services", mock)
|
||||
try:
|
||||
resp = client.get("/api/services/hf-pdocker-01/abc123/logs")
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "log line 1" in resp.json()["logs"]
|
||||
|
||||
def test_logs_passes_tail_param(self, client):
|
||||
mock = _make_mock_client(get=[
|
||||
_mock_response({"container": "test-app", "logs": "log data"}),
|
||||
])
|
||||
|
||||
p = _patch_httpx("server.routes.services", mock)
|
||||
try:
|
||||
resp = client.get("/api/services/hf-pdocker-01/abc123/logs?tail=50")
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
assert resp.status_code == 200
|
||||
# Verify tail param was passed through to the agent
|
||||
call_args = mock.get.call_args
|
||||
assert "tail" in str(call_args)
|
||||
|
||||
def test_logs_invalid_node_returns_404(self, client):
|
||||
resp = client.get("/api/services/nonexistent-node/abc123/logs")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/services/{node}/{container_id}/pull
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPullService:
|
||||
|
||||
def test_pull_proxies_to_agent(self, client):
|
||||
mock = _make_mock_client(post=[
|
||||
_mock_response({"success": True, "message": "Pulled nginx:latest"}),
|
||||
])
|
||||
|
||||
p = _patch_httpx("server.routes.services", mock)
|
||||
try:
|
||||
resp = client.post("/api/services/bart/abc123/pull")
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
def test_pull_invalid_node_returns_404(self, client):
|
||||
resp = client.post("/api/services/nonexistent-node/abc123/pull")
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user