Files
farm-manager/tests/test_server_proxy.py
T
2026-03-05 22:24:20 -06:00

348 lines
12 KiB
Python

"""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