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:
279
tests/unit/test_haproxy_client.py
Normal file
279
tests/unit/test_haproxy_client.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""Unit tests for haproxy_client module."""
|
||||
|
||||
import socket
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from haproxy_mcp.haproxy_client import (
|
||||
haproxy_cmd,
|
||||
haproxy_cmd_checked,
|
||||
haproxy_cmd_batch,
|
||||
reload_haproxy,
|
||||
)
|
||||
from haproxy_mcp.exceptions import HaproxyError
|
||||
|
||||
|
||||
class TestHaproxyCmd:
|
||||
"""Tests for haproxy_cmd function."""
|
||||
|
||||
def test_successful_command(self, mock_socket_class, mock_select):
|
||||
"""Successful command execution returns response."""
|
||||
mock_sock = mock_socket_class(
|
||||
responses={"show info": "Version: 3.3.2\nUptime_sec: 3600"}
|
||||
)
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
result = haproxy_cmd("show info")
|
||||
|
||||
assert "Version: 3.3.2" in result
|
||||
assert "show info" in mock_sock.sent_commands
|
||||
|
||||
def test_empty_response(self, mock_socket_class, mock_select):
|
||||
"""Command with empty response returns empty string."""
|
||||
mock_sock = mock_socket_class(default_response="")
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
result = haproxy_cmd("set server pool_1/pool_1_1 state ready")
|
||||
|
||||
assert result == ""
|
||||
|
||||
def test_connection_refused_error(self, mock_select):
|
||||
"""Connection refused raises HaproxyError."""
|
||||
with patch("socket.socket") as mock_socket:
|
||||
mock_socket.return_value.__enter__ = MagicMock(side_effect=ConnectionRefusedError())
|
||||
mock_socket.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with pytest.raises(HaproxyError) as exc_info:
|
||||
haproxy_cmd("show info")
|
||||
|
||||
assert "Connection refused" in str(exc_info.value)
|
||||
|
||||
def test_socket_timeout_error(self, mock_select):
|
||||
"""Socket timeout raises HaproxyError."""
|
||||
with patch("socket.socket") as mock_socket:
|
||||
mock_socket.return_value.__enter__ = MagicMock(side_effect=socket.timeout())
|
||||
mock_socket.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with pytest.raises(HaproxyError) as exc_info:
|
||||
haproxy_cmd("show info")
|
||||
|
||||
assert "timeout" in str(exc_info.value).lower()
|
||||
|
||||
def test_unicode_decode_error(self, mock_socket_class, mock_select):
|
||||
"""Invalid UTF-8 response raises HaproxyError."""
|
||||
# Create a mock that returns invalid UTF-8 bytes
|
||||
class BadUtf8Socket(mock_socket_class):
|
||||
def sendall(self, data):
|
||||
self.sent_commands.append(data.decode().strip())
|
||||
self._response_buffer = b"\xff\xfe" # Invalid UTF-8
|
||||
|
||||
mock_sock = BadUtf8Socket()
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
with pytest.raises(HaproxyError) as exc_info:
|
||||
haproxy_cmd("show info")
|
||||
|
||||
assert "UTF-8" in str(exc_info.value)
|
||||
|
||||
def test_multiline_response(self, mock_socket_class, mock_select):
|
||||
"""Multi-line response is properly returned."""
|
||||
multi_line = "pool_1\npool_2\npool_3"
|
||||
mock_sock = mock_socket_class(responses={"show backend": multi_line})
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
result = haproxy_cmd("show backend")
|
||||
|
||||
assert "pool_1" in result
|
||||
assert "pool_2" in result
|
||||
assert "pool_3" in result
|
||||
|
||||
|
||||
class TestHaproxyCmdChecked:
|
||||
"""Tests for haproxy_cmd_checked function."""
|
||||
|
||||
def test_successful_command(self, mock_socket_class, mock_select):
|
||||
"""Successful command returns response."""
|
||||
mock_sock = mock_socket_class(responses={"set server": ""})
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
result = haproxy_cmd_checked("set server pool_1/pool_1_1 state ready")
|
||||
|
||||
assert result == ""
|
||||
|
||||
def test_error_response_no_such(self, mock_socket_class, mock_select):
|
||||
"""Response containing 'No such' raises HaproxyError."""
|
||||
mock_sock = mock_socket_class(
|
||||
responses={"set server": "No such server."}
|
||||
)
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
with pytest.raises(HaproxyError) as exc_info:
|
||||
haproxy_cmd_checked("set server pool_99/pool_99_1 state ready")
|
||||
|
||||
assert "No such" in str(exc_info.value)
|
||||
|
||||
def test_error_response_not_found(self, mock_socket_class, mock_select):
|
||||
"""Response containing 'not found' raises HaproxyError."""
|
||||
mock_sock = mock_socket_class(
|
||||
responses={"del map": "Backend not found."}
|
||||
)
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
with pytest.raises(HaproxyError) as exc_info:
|
||||
haproxy_cmd_checked("del map /path example.com")
|
||||
|
||||
assert "not found" in str(exc_info.value)
|
||||
|
||||
def test_error_response_error(self, mock_socket_class, mock_select):
|
||||
"""Response containing 'error' raises HaproxyError."""
|
||||
mock_sock = mock_socket_class(
|
||||
responses={"set server": "error: invalid state"}
|
||||
)
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
with pytest.raises(HaproxyError) as exc_info:
|
||||
haproxy_cmd_checked("set server pool_1/pool_1_1 state invalid")
|
||||
|
||||
assert "error" in str(exc_info.value).lower()
|
||||
|
||||
def test_error_response_failed(self, mock_socket_class, mock_select):
|
||||
"""Response containing 'failed' raises HaproxyError."""
|
||||
mock_sock = mock_socket_class(
|
||||
responses={"set server": "Command failed"}
|
||||
)
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
with pytest.raises(HaproxyError) as exc_info:
|
||||
haproxy_cmd_checked("set server pool_1/pool_1_1 addr bad")
|
||||
|
||||
assert "failed" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
class TestHaproxyCmdBatch:
|
||||
"""Tests for haproxy_cmd_batch function."""
|
||||
|
||||
def test_empty_commands(self):
|
||||
"""Empty command list returns empty list."""
|
||||
result = haproxy_cmd_batch([])
|
||||
assert result == []
|
||||
|
||||
def test_single_command(self, mock_socket_class, mock_select):
|
||||
"""Single command uses haproxy_cmd_checked."""
|
||||
mock_sock = mock_socket_class(responses={"set server": ""})
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
result = haproxy_cmd_batch(["set server pool_1/pool_1_1 state ready"])
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
def test_multiple_commands(self, mock_socket_class, mock_select):
|
||||
"""Multiple commands are executed separately."""
|
||||
# Each command gets its own socket connection
|
||||
call_count = 0
|
||||
def create_mock_socket(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return mock_socket_class(responses={"set server": ""})
|
||||
|
||||
with patch("socket.socket", side_effect=create_mock_socket):
|
||||
result = haproxy_cmd_batch([
|
||||
"set server pool_1/pool_1_1 addr 10.0.0.1 port 80",
|
||||
"set server pool_1/pool_1_1 state ready",
|
||||
])
|
||||
|
||||
assert len(result) == 2
|
||||
assert call_count == 2 # One connection per command
|
||||
|
||||
def test_error_in_batch_raises(self, mock_socket_class, mock_select):
|
||||
"""Error in batch command raises immediately."""
|
||||
mock_sock = mock_socket_class(
|
||||
responses={
|
||||
"set server pool_1/pool_1_1 addr": "",
|
||||
"set server pool_1/pool_1_1 state": "No such server",
|
||||
}
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
def create_socket(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return mock_socket_class(responses={"set server": ""})
|
||||
else:
|
||||
return mock_socket_class(responses={"set server": "No such server"})
|
||||
|
||||
with patch("socket.socket", side_effect=create_socket):
|
||||
with pytest.raises(HaproxyError):
|
||||
haproxy_cmd_batch([
|
||||
"set server pool_1/pool_1_1 addr 10.0.0.1 port 80",
|
||||
"set server pool_1/pool_1_1 state ready",
|
||||
])
|
||||
|
||||
|
||||
class TestReloadHaproxy:
|
||||
"""Tests for reload_haproxy function."""
|
||||
|
||||
def test_successful_reload(self, mock_subprocess):
|
||||
"""Successful reload returns (True, 'OK')."""
|
||||
mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
success, message = reload_haproxy()
|
||||
|
||||
assert success is True
|
||||
assert message == "OK"
|
||||
|
||||
def test_validation_failure(self, mock_subprocess):
|
||||
"""Config validation failure returns (False, error)."""
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="[ALERT] Invalid configuration"
|
||||
)
|
||||
|
||||
success, message = reload_haproxy()
|
||||
|
||||
assert success is False
|
||||
assert "validation failed" in message.lower()
|
||||
assert "Invalid configuration" in message
|
||||
|
||||
def test_reload_failure(self, mock_subprocess):
|
||||
"""Reload command failure returns (False, error)."""
|
||||
# First call (validation) succeeds, second call (reload) fails
|
||||
mock_subprocess.side_effect = [
|
||||
MagicMock(returncode=0, stdout="", stderr=""),
|
||||
MagicMock(returncode=1, stdout="", stderr="Container not found"),
|
||||
]
|
||||
|
||||
success, message = reload_haproxy()
|
||||
|
||||
assert success is False
|
||||
assert "Reload failed" in message
|
||||
|
||||
def test_podman_not_found(self, mock_subprocess):
|
||||
"""Podman not found returns (False, error)."""
|
||||
mock_subprocess.side_effect = FileNotFoundError()
|
||||
|
||||
success, message = reload_haproxy()
|
||||
|
||||
assert success is False
|
||||
assert "podman" in message.lower()
|
||||
|
||||
def test_subprocess_timeout(self, mock_subprocess):
|
||||
"""Subprocess timeout returns (False, error)."""
|
||||
import subprocess
|
||||
mock_subprocess.side_effect = subprocess.TimeoutExpired("podman", 30)
|
||||
|
||||
success, message = reload_haproxy()
|
||||
|
||||
assert success is False
|
||||
assert "timed out" in message.lower()
|
||||
|
||||
def test_os_error(self, mock_subprocess):
|
||||
"""OS error returns (False, error)."""
|
||||
mock_subprocess.side_effect = OSError("Permission denied")
|
||||
|
||||
success, message = reload_haproxy()
|
||||
|
||||
assert success is False
|
||||
assert "OS error" in message
|
||||
Reference in New Issue
Block a user