mcmqtt/tests/unit/test_mqtt_connection.py
Ryan Malloy 8ab61eb1df 🚀 Initial release: mcmqtt FastMCP MQTT Server v2025.09.17
Complete FastMCP MQTT integration server featuring:

 Core Features:
- FastMCP native Model Context Protocol server with MQTT tools
- Embedded MQTT broker support with zero-configuration spawning
- Modular architecture: CLI, config, logging, server, MQTT, MCP, broker
- Comprehensive testing: 70+ tests with 96%+ coverage
- Cross-platform support: Linux, macOS, Windows

🏗️ Architecture:
- Clean separation of concerns across 7 modules
- Async/await patterns throughout for maximum performance
- Pydantic models with validation and configuration management
- AMQTT pure Python embedded brokers
- Typer CLI framework with rich output formatting

🧪 Quality Assurance:
- pytest-cov with HTML reporting
- AsyncMock comprehensive unit testing
- Edge case coverage for production reliability
- Pre-commit hooks with black, ruff, mypy

📦 Production Ready:
- PyPI package with proper metadata
- MIT License
- Professional documentation
- uvx installation support
- MCP client integration examples

Perfect for AI agent coordination, IoT data collection, and
microservice communication with MQTT messaging patterns.
2025-09-17 05:46:08 -06:00

668 lines
24 KiB
Python

"""Tests for MQTT connection management."""
import asyncio
import ssl
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch, call
import pytest
from mcmqtt.mqtt.connection import MQTTConnectionManager
from mcmqtt.mqtt.types import MQTTConfig, MQTTConnectionState, MQTTQoS
@pytest.fixture
def mqtt_config():
"""Create test MQTT config."""
return MQTTConfig(
broker_host="localhost",
broker_port=1883,
client_id="test-client",
username="testuser",
password="testpass",
keepalive=60,
qos=MQTTQoS.AT_LEAST_ONCE,
clean_session=True,
reconnect_interval=5,
max_reconnect_attempts=3
)
@pytest.fixture
def tls_config():
"""Create test MQTT config with TLS."""
return MQTTConfig(
broker_host="localhost",
broker_port=8883,
client_id="test-client",
use_tls=True,
ca_cert_path="/path/to/ca.pem",
cert_path="/path/to/cert.pem",
key_path="/path/to/key.pem"
)
@pytest.fixture
def will_config():
"""Create test MQTT config with last will."""
return MQTTConfig(
broker_host="localhost",
broker_port=1883,
client_id="test-client",
will_topic="status/client",
will_payload="offline",
will_qos=MQTTQoS.AT_LEAST_ONCE,
will_retain=True
)
class TestMQTTConnectionManager:
"""Test MQTT connection manager."""
def test_init(self, mqtt_config):
"""Test connection manager initialization."""
manager = MQTTConnectionManager(mqtt_config)
assert manager.config == mqtt_config
assert manager.state == MQTTConnectionState.DISCONNECTED
assert not manager.is_connected
assert manager._client is None
assert manager._reconnect_task is None
assert manager._reconnect_attempts == 0
def test_properties(self, mqtt_config):
"""Test connection manager properties."""
manager = MQTTConnectionManager(mqtt_config)
# Test state property
assert manager.state == MQTTConnectionState.DISCONNECTED
# Test is_connected property
assert not manager.is_connected
manager._state = MQTTConnectionState.CONNECTED
assert manager.is_connected
# Test connection_info property
info = manager.connection_info
assert info.state == MQTTConnectionState.CONNECTED
assert info.broker_host == "localhost"
assert info.broker_port == 1883
assert info.client_id == "test-client"
def test_set_callbacks(self, mqtt_config):
"""Test setting callbacks."""
manager = MQTTConnectionManager(mqtt_config)
on_connect = AsyncMock()
on_disconnect = AsyncMock()
on_message = AsyncMock()
on_error = AsyncMock()
manager.set_callbacks(
on_connect=on_connect,
on_disconnect=on_disconnect,
on_message=on_message,
on_error=on_error
)
assert manager._on_connect == on_connect
assert manager._on_disconnect == on_disconnect
assert manager._on_message == on_message
assert manager._on_error == on_error
@pytest.mark.asyncio
@patch('paho.mqtt.client.Client')
async def test_connect_success(self, mock_client_class, mqtt_config):
"""Test successful connection."""
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.connect.return_value = 0 # MQTT_ERR_SUCCESS
manager = MQTTConnectionManager(mqtt_config)
# Simulate the state change that would happen in the actual connection process
def simulate_connect(*args):
# Simulate the paho callback that sets state to CONNECTED
manager._state = MQTTConnectionState.CONNECTED
return 0
mock_client.connect.side_effect = simulate_connect
result = await manager.connect()
assert result is True
assert manager.state == MQTTConnectionState.CONNECTED
mock_client.connect.assert_called_once_with("localhost", 1883, 60)
mock_client.loop_start.assert_called_once()
@pytest.mark.asyncio
@patch('paho.mqtt.client.Client')
async def test_connect_already_connected(self, mock_client_class, mqtt_config):
"""Test connect when already connected."""
manager = MQTTConnectionManager(mqtt_config)
manager._state = MQTTConnectionState.CONNECTED
result = await manager.connect()
assert result is True
mock_client_class.assert_not_called()
@pytest.mark.asyncio
@patch('paho.mqtt.client.Client')
async def test_connect_with_auth(self, mock_client_class, mqtt_config):
"""Test connection with authentication."""
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.connect.return_value = 0
manager = MQTTConnectionManager(mqtt_config)
def simulate_connect(*args):
manager._state = MQTTConnectionState.CONNECTED
mock_client.connect.side_effect = simulate_connect
await manager.connect()
mock_client.username_pw_set.assert_called_once_with("testuser", "testpass")
@pytest.mark.asyncio
@patch('paho.mqtt.client.Client')
@patch('ssl.create_default_context')
async def test_connect_with_tls(self, mock_ssl_context, mock_client_class, tls_config):
"""Test connection with TLS."""
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.connect.return_value = 0
mock_context = MagicMock()
mock_ssl_context.return_value = mock_context
manager = MQTTConnectionManager(tls_config)
def simulate_connect(*args):
manager._state = MQTTConnectionState.CONNECTED
mock_client.connect.side_effect = simulate_connect
await manager.connect()
mock_ssl_context.assert_called_once()
mock_context.load_verify_locations.assert_called_once_with("/path/to/ca.pem")
mock_context.load_cert_chain.assert_called_once_with("/path/to/cert.pem", "/path/to/key.pem")
mock_client.tls_set_context.assert_called_once_with(mock_context)
@pytest.mark.asyncio
@patch('paho.mqtt.client.Client')
async def test_connect_with_will(self, mock_client_class, will_config):
"""Test connection with last will and testament."""
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.connect.return_value = 0
manager = MQTTConnectionManager(will_config)
def simulate_connect(*args):
manager._state = MQTTConnectionState.CONNECTED
mock_client.connect.side_effect = simulate_connect
await manager.connect()
mock_client.will_set.assert_called_once_with(
"status/client", "offline", qos=1, retain=True
)
@pytest.mark.asyncio
@patch('paho.mqtt.client.Client')
async def test_connect_failure(self, mock_client_class, mqtt_config):
"""Test connection failure."""
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.connect.return_value = 1 # Connection failed
manager = MQTTConnectionManager(mqtt_config)
result = await manager.connect()
assert result is False
assert manager.state == MQTTConnectionState.ERROR
mock_client.loop_stop.assert_called_once()
@pytest.mark.asyncio
@patch('paho.mqtt.client.Client')
async def test_connect_exception(self, mock_client_class, mqtt_config):
"""Test connection with exception."""
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.connect.side_effect = Exception("Connection error")
manager = MQTTConnectionManager(mqtt_config)
result = await manager.connect()
assert result is False
assert manager.state == MQTTConnectionState.ERROR
@pytest.mark.asyncio
async def test_disconnect_not_connected(self, mqtt_config):
"""Test disconnect when not connected."""
manager = MQTTConnectionManager(mqtt_config)
result = await manager.disconnect()
assert result is True
@pytest.mark.asyncio
async def test_disconnect_success(self, mqtt_config):
"""Test successful disconnect."""
manager = MQTTConnectionManager(mqtt_config)
mock_client = MagicMock()
manager._client = mock_client
manager._state = MQTTConnectionState.CONNECTED
result = await manager.disconnect()
assert result is True
assert manager.state == MQTTConnectionState.DISCONNECTED
mock_client.disconnect.assert_called_once()
mock_client.loop_stop.assert_called_once()
assert manager._client is None # Client is set to None after disconnect
@pytest.mark.asyncio
async def test_disconnect_with_reconnect_task(self, mqtt_config):
"""Test disconnect with active reconnect task."""
manager = MQTTConnectionManager(mqtt_config)
mock_client = MagicMock()
mock_reconnect_task = MagicMock()
manager._client = mock_client
manager._reconnect_task = mock_reconnect_task
manager._state = MQTTConnectionState.CONNECTED
result = await manager.disconnect()
assert result is True
mock_reconnect_task.cancel.assert_called_once()
assert manager._reconnect_task is None # Task is set to None after cancel
@pytest.mark.asyncio
async def test_disconnect_exception(self, mqtt_config):
"""Test disconnect with exception."""
manager = MQTTConnectionManager(mqtt_config)
manager._client = MagicMock()
manager._client.disconnect.side_effect = Exception("Disconnect error")
manager._state = MQTTConnectionState.CONNECTED
result = await manager.disconnect()
assert result is False
@pytest.mark.asyncio
async def test_publish_success(self, mqtt_config):
"""Test successful publish."""
manager = MQTTConnectionManager(mqtt_config)
manager._client = MagicMock()
manager._state = MQTTConnectionState.CONNECTED
mock_result = MagicMock()
mock_result.rc = 0 # MQTT_ERR_SUCCESS
manager._client.publish.return_value = mock_result
result = await manager.publish("test/topic", "test message")
assert result is True
manager._client.publish.assert_called_once_with(
"test/topic", "test message", qos=1, retain=False
)
@pytest.mark.asyncio
async def test_publish_not_connected(self, mqtt_config):
"""Test publish when not connected."""
manager = MQTTConnectionManager(mqtt_config)
result = await manager.publish("test/topic", "test message")
assert result is False
@pytest.mark.asyncio
async def test_publish_with_qos(self, mqtt_config):
"""Test publish with specific QoS."""
manager = MQTTConnectionManager(mqtt_config)
manager._client = MagicMock()
manager._state = MQTTConnectionState.CONNECTED
mock_result = MagicMock()
mock_result.rc = 0
manager._client.publish.return_value = mock_result
result = await manager.publish("test/topic", "test message",
qos=MQTTQoS.EXACTLY_ONCE, retain=True)
assert result is True
manager._client.publish.assert_called_once_with(
"test/topic", "test message", qos=2, retain=True
)
@pytest.mark.asyncio
async def test_publish_failure(self, mqtt_config):
"""Test publish failure."""
manager = MQTTConnectionManager(mqtt_config)
manager._client = MagicMock()
manager._state = MQTTConnectionState.CONNECTED
mock_result = MagicMock()
mock_result.rc = 1 # Error
manager._client.publish.return_value = mock_result
result = await manager.publish("test/topic", "test message")
assert result is False
@pytest.mark.asyncio
async def test_publish_exception(self, mqtt_config):
"""Test publish with exception."""
manager = MQTTConnectionManager(mqtt_config)
manager._client = MagicMock()
manager._state = MQTTConnectionState.CONNECTED
manager._client.publish.side_effect = Exception("Publish error")
result = await manager.publish("test/topic", "test message")
assert result is False
@pytest.mark.asyncio
async def test_subscribe_success(self, mqtt_config):
"""Test successful subscribe."""
manager = MQTTConnectionManager(mqtt_config)
manager._client = MagicMock()
manager._state = MQTTConnectionState.CONNECTED
manager._client.subscribe.return_value = (0, 1) # (result, mid)
result = await manager.subscribe("test/topic")
assert result is True
manager._client.subscribe.assert_called_once_with("test/topic", qos=1)
@pytest.mark.asyncio
async def test_subscribe_not_connected(self, mqtt_config):
"""Test subscribe when not connected."""
manager = MQTTConnectionManager(mqtt_config)
result = await manager.subscribe("test/topic")
assert result is False
@pytest.mark.asyncio
async def test_subscribe_with_qos(self, mqtt_config):
"""Test subscribe with specific QoS."""
manager = MQTTConnectionManager(mqtt_config)
manager._client = MagicMock()
manager._state = MQTTConnectionState.CONNECTED
manager._client.subscribe.return_value = (0, 1)
result = await manager.subscribe("test/topic", MQTTQoS.EXACTLY_ONCE)
assert result is True
manager._client.subscribe.assert_called_once_with("test/topic", qos=2)
@pytest.mark.asyncio
async def test_subscribe_failure(self, mqtt_config):
"""Test subscribe failure."""
manager = MQTTConnectionManager(mqtt_config)
manager._client = MagicMock()
manager._state = MQTTConnectionState.CONNECTED
manager._client.subscribe.return_value = (1, 1) # Error
result = await manager.subscribe("test/topic")
assert result is False
@pytest.mark.asyncio
async def test_subscribe_exception(self, mqtt_config):
"""Test subscribe with exception."""
manager = MQTTConnectionManager(mqtt_config)
manager._client = MagicMock()
manager._state = MQTTConnectionState.CONNECTED
manager._client.subscribe.side_effect = Exception("Subscribe error")
result = await manager.subscribe("test/topic")
assert result is False
@pytest.mark.asyncio
async def test_unsubscribe_success(self, mqtt_config):
"""Test successful unsubscribe."""
manager = MQTTConnectionManager(mqtt_config)
manager._client = MagicMock()
manager._state = MQTTConnectionState.CONNECTED
manager._client.unsubscribe.return_value = (0, 1)
result = await manager.unsubscribe("test/topic")
assert result is True
manager._client.unsubscribe.assert_called_once_with("test/topic")
@pytest.mark.asyncio
async def test_unsubscribe_not_connected(self, mqtt_config):
"""Test unsubscribe when not connected."""
manager = MQTTConnectionManager(mqtt_config)
result = await manager.unsubscribe("test/topic")
assert result is False
@pytest.mark.asyncio
async def test_unsubscribe_failure(self, mqtt_config):
"""Test unsubscribe failure."""
manager = MQTTConnectionManager(mqtt_config)
manager._client = MagicMock()
manager._state = MQTTConnectionState.CONNECTED
manager._client.unsubscribe.return_value = (1, 1) # Error
result = await manager.unsubscribe("test/topic")
assert result is False
@pytest.mark.asyncio
async def test_unsubscribe_exception(self, mqtt_config):
"""Test unsubscribe with exception."""
manager = MQTTConnectionManager(mqtt_config)
manager._client = MagicMock()
manager._state = MQTTConnectionState.CONNECTED
manager._client.unsubscribe.side_effect = Exception("Unsubscribe error")
result = await manager.unsubscribe("test/topic")
assert result is False
def test_set_state(self, mqtt_config):
"""Test state setting."""
manager = MQTTConnectionManager(mqtt_config)
manager._set_state(MQTTConnectionState.CONNECTING)
assert manager.state == MQTTConnectionState.CONNECTING
manager._set_state(MQTTConnectionState.CONNECTED)
assert manager.state == MQTTConnectionState.CONNECTED
assert manager._connected_at is not None
manager._set_state(MQTTConnectionState.DISCONNECTED)
assert manager.state == MQTTConnectionState.DISCONNECTED
assert manager._connected_at is None
def test_set_state_with_error(self, mqtt_config):
"""Test state setting with error message."""
manager = MQTTConnectionManager(mqtt_config)
manager._set_state(MQTTConnectionState.ERROR, "Test error")
assert manager.state == MQTTConnectionState.ERROR
assert manager.connection_info.error_message == "Test error"
@pytest.mark.asyncio
async def test_paho_connect_callback_success(self, mqtt_config):
"""Test paho connect callback success."""
manager = MQTTConnectionManager(mqtt_config)
manager._loop = asyncio.get_event_loop()
on_connect = AsyncMock()
manager.set_callbacks(on_connect=on_connect)
manager._on_paho_connect(None, None, None, 0) # rc=0 = success
assert manager.state == MQTTConnectionState.CONNECTED
await asyncio.sleep(0.01) # Let callback task run
on_connect.assert_called_once()
@pytest.mark.asyncio
async def test_paho_connect_callback_failure(self, mqtt_config):
"""Test paho connect callback failure."""
manager = MQTTConnectionManager(mqtt_config)
manager._loop = asyncio.get_event_loop()
on_error = AsyncMock()
manager.set_callbacks(on_error=on_error)
manager._on_paho_connect(None, None, None, 1) # rc=1 = failure
assert manager.state == MQTTConnectionState.ERROR
await asyncio.sleep(0.01) # Let callback task run
on_error.assert_called_once()
@pytest.mark.asyncio
async def test_paho_disconnect_callback_clean(self, mqtt_config):
"""Test paho disconnect callback (clean)."""
manager = MQTTConnectionManager(mqtt_config)
manager._loop = asyncio.get_event_loop()
on_disconnect = AsyncMock()
manager.set_callbacks(on_disconnect=on_disconnect)
manager._on_paho_disconnect(None, None, 0) # rc=0 = clean disconnect
assert manager.state == MQTTConnectionState.DISCONNECTED
await asyncio.sleep(0.01) # Let callback task run
on_disconnect.assert_called_once_with(0)
@pytest.mark.asyncio
@patch('asyncio.create_task')
async def test_paho_disconnect_callback_unexpected(self, mock_create_task, mqtt_config):
"""Test paho disconnect callback (unexpected)."""
manager = MQTTConnectionManager(mqtt_config)
manager._loop = asyncio.get_event_loop()
on_disconnect = AsyncMock()
manager.set_callbacks(on_disconnect=on_disconnect)
manager._on_paho_disconnect(None, None, 1) # rc=1 = unexpected disconnect
assert manager.state == MQTTConnectionState.ERROR
# Should start reconnect
mock_create_task.assert_called()
@pytest.mark.asyncio
async def test_paho_message_callback(self, mqtt_config):
"""Test paho message callback."""
manager = MQTTConnectionManager(mqtt_config)
manager._loop = asyncio.get_event_loop()
on_message = AsyncMock()
manager.set_callbacks(on_message=on_message)
mock_msg = MagicMock()
mock_msg.topic = "test/topic"
mock_msg.payload = b"test payload"
mock_msg.qos = 1
mock_msg.retain = False
manager._on_paho_message(None, None, mock_msg)
await asyncio.sleep(0.01) # Let callback task run
on_message.assert_called_once_with("test/topic", b"test payload", 1, False)
def test_paho_log_callback(self, mqtt_config):
"""Test paho log callback."""
manager = MQTTConnectionManager(mqtt_config)
with patch('mcmqtt.mqtt.connection.logger') as mock_logger:
manager._on_paho_log(None, None, 16, "Test log message")
mock_logger.debug.assert_called_once_with("MQTT Log [16]: Test log message")
@pytest.mark.asyncio
@patch('asyncio.create_task')
async def test_start_reconnect(self, mock_create_task, mqtt_config):
"""Test starting reconnection."""
manager = MQTTConnectionManager(mqtt_config)
manager._start_reconnect()
mock_create_task.assert_called_once()
@pytest.mark.asyncio
@patch('asyncio.create_task')
async def test_start_reconnect_max_attempts_reached(self, mock_create_task, mqtt_config):
"""Test reconnect not started when max attempts reached."""
manager = MQTTConnectionManager(mqtt_config)
manager._reconnect_attempts = 3 # equals max_reconnect_attempts
manager._start_reconnect()
mock_create_task.assert_not_called()
@pytest.mark.asyncio
@patch('asyncio.create_task')
async def test_start_reconnect_task_already_running(self, mock_create_task, mqtt_config):
"""Test reconnect not started when task already running."""
manager = MQTTConnectionManager(mqtt_config)
manager._reconnect_task = MagicMock() # Already running
manager._start_reconnect()
mock_create_task.assert_not_called()
@pytest.mark.asyncio
async def test_reconnect_loop_success(self, mqtt_config):
"""Test successful reconnection loop."""
manager = MQTTConnectionManager(mqtt_config)
with patch.object(manager, 'connect', return_value=True) as mock_connect:
await manager._reconnect_loop()
mock_connect.assert_called_once()
assert manager._reconnect_attempts == 1
@pytest.mark.asyncio
async def test_reconnect_loop_max_attempts(self, mqtt_config):
"""Test reconnection loop reaching max attempts."""
manager = MQTTConnectionManager(mqtt_config)
with patch.object(manager, 'connect', return_value=False) as mock_connect, \
patch('asyncio.sleep') as mock_sleep:
await manager._reconnect_loop()
assert mock_connect.call_count == 3 # max_reconnect_attempts
assert manager._reconnect_attempts == 3
assert manager.state == MQTTConnectionState.ERROR
assert mock_sleep.call_count == 3 # Called before each attempt
def test_import_all_dependencies():
"""Test that all required dependencies can be imported."""
from mcmqtt.mqtt.connection import (
asyncio, logging, ssl, datetime,
MQTTConnectionManager, mqtt, PahoMessage,
MQTTConfig, MQTTConnectionState, MQTTConnectionInfo, MQTTQoS
)
# All imports should succeed
assert asyncio is not None
assert logging is not None
assert ssl is not None
assert datetime is not None
assert MQTTConnectionManager is not None
assert mqtt is not None
assert PahoMessage is not None
assert MQTTConfig is not None
assert MQTTConnectionState is not None
assert MQTTConnectionInfo is not None
assert MQTTQoS is not None