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