refactor: Improve code quality, error handling, and test coverage
- Add file_lock context manager to eliminate duplicate locking patterns - Add ValidationError, ConfigurationError, CertificateError exceptions - Improve rollback logic in haproxy_add_servers (track successful ops only) - Decompose haproxy_add_domain into smaller helper functions - Consolidate certificate constants (CERTS_DIR, ACME_HOME) to config.py - Enhance docstrings for internal functions and magic numbers - Add pytest framework with 48 new tests (269 -> 317 total) - Increase test coverage from 76% to 86% - servers.py: 58% -> 82% - certificates.py: 67% -> 86% - configuration.py: 69% -> 94% Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
433
tests/unit/tools/test_health.py
Normal file
433
tests/unit/tools/test_health.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""Unit tests for health check tools."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from haproxy_mcp.exceptions import HaproxyError
|
||||
|
||||
|
||||
class TestHaproxyHealth:
|
||||
"""Tests for haproxy_health tool function."""
|
||||
|
||||
def test_health_all_ok(self, mock_socket_class, mock_select, patch_config_paths, response_builder, mock_subprocess):
|
||||
"""Health check returns healthy when all components are OK."""
|
||||
mock_sock = mock_socket_class(responses={
|
||||
"show info": response_builder.info(version="3.3.2", uptime=3600),
|
||||
})
|
||||
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="running"
|
||||
)
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result_str = registered_tools["haproxy_health"]()
|
||||
result = json.loads(result_str)
|
||||
|
||||
assert result["status"] == "healthy"
|
||||
assert result["components"]["mcp"]["status"] == "ok"
|
||||
assert result["components"]["haproxy"]["status"] == "ok"
|
||||
assert result["components"]["haproxy"]["version"] == "3.3.2"
|
||||
|
||||
def test_health_haproxy_error(self, mock_socket_class, mock_select, patch_config_paths, mock_subprocess):
|
||||
"""Health check returns degraded when HAProxy is unreachable."""
|
||||
|
||||
def raise_error(*args, **kwargs):
|
||||
raise ConnectionRefusedError()
|
||||
|
||||
with patch("socket.socket", side_effect=raise_error):
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result_str = registered_tools["haproxy_health"]()
|
||||
result = json.loads(result_str)
|
||||
|
||||
assert result["status"] == "degraded"
|
||||
assert result["components"]["haproxy"]["status"] == "error"
|
||||
|
||||
def test_health_missing_config_files(self, mock_socket_class, mock_select, tmp_path, response_builder, mock_subprocess):
|
||||
"""Health check returns degraded when config files are missing."""
|
||||
mock_sock = mock_socket_class(responses={
|
||||
"show info": response_builder.info(),
|
||||
})
|
||||
|
||||
mock_subprocess.return_value = MagicMock(returncode=0, stdout="running")
|
||||
|
||||
# Use paths that don't exist
|
||||
with patch("haproxy_mcp.tools.health.MAP_FILE", str(tmp_path / "nonexistent.map")):
|
||||
with patch("haproxy_mcp.tools.health.SERVERS_FILE", str(tmp_path / "nonexistent.json")):
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result_str = registered_tools["haproxy_health"]()
|
||||
result = json.loads(result_str)
|
||||
|
||||
assert result["status"] == "degraded"
|
||||
assert result["components"]["config_files"]["status"] == "warning"
|
||||
|
||||
def test_health_container_not_running(self, mock_socket_class, mock_select, patch_config_paths, response_builder, mock_subprocess):
|
||||
"""Health check returns unhealthy when container is not running."""
|
||||
mock_sock = mock_socket_class(responses={
|
||||
"show info": response_builder.info(),
|
||||
})
|
||||
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="No such container"
|
||||
)
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result_str = registered_tools["haproxy_health"]()
|
||||
result = json.loads(result_str)
|
||||
|
||||
assert result["status"] == "unhealthy"
|
||||
assert result["components"]["container"]["status"] == "error"
|
||||
|
||||
|
||||
class TestHaproxyDomainHealth:
|
||||
"""Tests for haproxy_domain_health tool function."""
|
||||
|
||||
def test_domain_health_invalid_domain(self, patch_config_paths):
|
||||
"""Reject invalid domain format."""
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result_str = registered_tools["haproxy_domain_health"](domain="-invalid")
|
||||
result = json.loads(result_str)
|
||||
|
||||
assert "error" in result
|
||||
assert "Invalid domain" in result["error"]
|
||||
|
||||
def test_domain_health_healthy(self, mock_socket_class, mock_select, patch_config_paths, response_builder):
|
||||
"""Domain health returns healthy when all servers are UP."""
|
||||
with open(patch_config_paths["map_file"], "w") as f:
|
||||
f.write("example.com pool_1\n")
|
||||
|
||||
mock_sock = mock_socket_class(responses={
|
||||
"show servers state": response_builder.servers_state([
|
||||
{"be_name": "pool_1", "srv_name": "pool_1_1", "srv_addr": "10.0.0.1", "srv_port": 80},
|
||||
{"be_name": "pool_1", "srv_name": "pool_1_2", "srv_addr": "10.0.0.2", "srv_port": 80},
|
||||
]),
|
||||
"show stat": response_builder.stat_csv([
|
||||
{"pxname": "pool_1", "svname": "pool_1_1", "status": "UP", "check_status": "L4OK"},
|
||||
{"pxname": "pool_1", "svname": "pool_1_2", "status": "UP", "check_status": "L4OK"},
|
||||
]),
|
||||
})
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result_str = registered_tools["haproxy_domain_health"](domain="example.com")
|
||||
result = json.loads(result_str)
|
||||
|
||||
assert result["status"] == "healthy"
|
||||
assert result["healthy_count"] == 2
|
||||
assert result["total_count"] == 2
|
||||
|
||||
def test_domain_health_degraded(self, mock_socket_class, mock_select, patch_config_paths, response_builder):
|
||||
"""Domain health returns degraded when some servers are DOWN."""
|
||||
with open(patch_config_paths["map_file"], "w") as f:
|
||||
f.write("example.com pool_1\n")
|
||||
|
||||
mock_sock = mock_socket_class(responses={
|
||||
"show servers state": response_builder.servers_state([
|
||||
{"be_name": "pool_1", "srv_name": "pool_1_1", "srv_addr": "10.0.0.1", "srv_port": 80},
|
||||
{"be_name": "pool_1", "srv_name": "pool_1_2", "srv_addr": "10.0.0.2", "srv_port": 80},
|
||||
]),
|
||||
"show stat": response_builder.stat_csv([
|
||||
{"pxname": "pool_1", "svname": "pool_1_1", "status": "UP"},
|
||||
{"pxname": "pool_1", "svname": "pool_1_2", "status": "DOWN"},
|
||||
]),
|
||||
})
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result_str = registered_tools["haproxy_domain_health"](domain="example.com")
|
||||
result = json.loads(result_str)
|
||||
|
||||
assert result["status"] == "degraded"
|
||||
assert result["healthy_count"] == 1
|
||||
assert result["total_count"] == 2
|
||||
|
||||
def test_domain_health_down(self, mock_socket_class, mock_select, patch_config_paths, response_builder):
|
||||
"""Domain health returns down when all servers are DOWN."""
|
||||
with open(patch_config_paths["map_file"], "w") as f:
|
||||
f.write("example.com pool_1\n")
|
||||
|
||||
mock_sock = mock_socket_class(responses={
|
||||
"show servers state": response_builder.servers_state([
|
||||
{"be_name": "pool_1", "srv_name": "pool_1_1", "srv_addr": "10.0.0.1", "srv_port": 80},
|
||||
]),
|
||||
"show stat": response_builder.stat_csv([
|
||||
{"pxname": "pool_1", "svname": "pool_1_1", "status": "DOWN"},
|
||||
]),
|
||||
})
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result_str = registered_tools["haproxy_domain_health"](domain="example.com")
|
||||
result = json.loads(result_str)
|
||||
|
||||
assert result["status"] == "down"
|
||||
assert result["healthy_count"] == 0
|
||||
assert result["total_count"] == 1
|
||||
|
||||
def test_domain_health_no_servers(self, mock_socket_class, mock_select, patch_config_paths, response_builder):
|
||||
"""Domain health returns no_servers when no servers configured."""
|
||||
with open(patch_config_paths["map_file"], "w") as f:
|
||||
f.write("example.com pool_1\n")
|
||||
|
||||
mock_sock = mock_socket_class(responses={
|
||||
"show servers state": response_builder.servers_state([
|
||||
{"be_name": "pool_1", "srv_name": "pool_1_1", "srv_addr": "0.0.0.0", "srv_port": 0},
|
||||
]),
|
||||
"show stat": response_builder.stat_csv([]),
|
||||
})
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result_str = registered_tools["haproxy_domain_health"](domain="example.com")
|
||||
result = json.loads(result_str)
|
||||
|
||||
assert result["status"] == "no_servers"
|
||||
assert result["total_count"] == 0
|
||||
|
||||
|
||||
class TestHaproxyGetServerHealth:
|
||||
"""Tests for haproxy_get_server_health tool function."""
|
||||
|
||||
def test_get_server_health_invalid_backend(self, patch_config_paths):
|
||||
"""Reject invalid backend name."""
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result = registered_tools["haproxy_get_server_health"](backend="invalid@name")
|
||||
|
||||
assert "Error" in result
|
||||
assert "Invalid backend" in result
|
||||
|
||||
def test_get_server_health_all_backends(self, mock_socket_class, mock_select, patch_config_paths, response_builder):
|
||||
"""Get health for all backends."""
|
||||
mock_sock = mock_socket_class(responses={
|
||||
"show stat": response_builder.stat_csv([
|
||||
{"pxname": "pool_1", "svname": "pool_1_1", "status": "UP", "weight": 1, "check_status": "L4OK"},
|
||||
{"pxname": "pool_2", "svname": "pool_2_1", "status": "DOWN", "weight": 1, "check_status": "L4TOUT"},
|
||||
]),
|
||||
})
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result = registered_tools["haproxy_get_server_health"](backend="")
|
||||
|
||||
assert "pool_1" in result
|
||||
assert "pool_2" in result
|
||||
assert "UP" in result
|
||||
assert "DOWN" in result
|
||||
|
||||
def test_get_server_health_filter_backend(self, mock_socket_class, mock_select, patch_config_paths, response_builder):
|
||||
"""Get health for specific backend."""
|
||||
mock_sock = mock_socket_class(responses={
|
||||
"show stat": response_builder.stat_csv([
|
||||
{"pxname": "pool_1", "svname": "pool_1_1", "status": "UP"},
|
||||
{"pxname": "pool_1", "svname": "pool_1_2", "status": "UP"},
|
||||
{"pxname": "pool_2", "svname": "pool_2_1", "status": "DOWN"},
|
||||
]),
|
||||
})
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result = registered_tools["haproxy_get_server_health"](backend="pool_1")
|
||||
|
||||
assert "pool_1" in result
|
||||
assert "pool_2" not in result
|
||||
|
||||
def test_get_server_health_no_servers(self, mock_socket_class, mock_select, patch_config_paths, response_builder):
|
||||
"""No servers returns appropriate message."""
|
||||
mock_sock = mock_socket_class(responses={
|
||||
"show stat": response_builder.stat_csv([
|
||||
{"pxname": "pool_1", "svname": "FRONTEND", "status": "OPEN"},
|
||||
{"pxname": "pool_1", "svname": "BACKEND", "status": "UP"},
|
||||
]),
|
||||
})
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result = registered_tools["haproxy_get_server_health"](backend="")
|
||||
|
||||
assert "No servers found" in result
|
||||
|
||||
def test_get_server_health_haproxy_error(self, mock_socket_class, mock_select, patch_config_paths):
|
||||
"""HAProxy error returns error message."""
|
||||
def raise_error(*args, **kwargs):
|
||||
raise ConnectionRefusedError()
|
||||
|
||||
with patch("socket.socket", side_effect=raise_error):
|
||||
from haproxy_mcp.tools.health import register_health_tools
|
||||
mcp = MagicMock()
|
||||
registered_tools = {}
|
||||
|
||||
def capture_tool():
|
||||
def decorator(func):
|
||||
registered_tools[func.__name__] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
mcp.tool = capture_tool
|
||||
register_health_tools(mcp)
|
||||
|
||||
result = registered_tools["haproxy_get_server_health"](backend="")
|
||||
|
||||
assert "Error" in result
|
||||
Reference in New Issue
Block a user