- Update pyproject.toml to use new Typer-based CLI - Remove old mcmqtt.py and mcmqtt_old.py legacy files - Package now correctly loads modern CLI with proper commands - Version command works: mcmqtt version -> 2025.9.17 - Ready for PyPI publication
394 lines
13 KiB
Python
394 lines
13 KiB
Python
"""Tests for the main CLI application."""
|
|
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
import typer
|
|
from typer.testing import CliRunner
|
|
|
|
from mcmqtt.main import app, main, create_mqtt_config_from_env, get_version
|
|
|
|
|
|
class TestCLI:
|
|
"""Test cases for CLI functionality."""
|
|
|
|
def test_cli_app_creation(self):
|
|
"""Test CLI app is properly created."""
|
|
assert isinstance(app, typer.Typer)
|
|
assert app.info.name == "mcmqtt"
|
|
|
|
def test_version_command(self):
|
|
"""Test version command."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["version"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "mcmqtt version:" in result.stdout
|
|
|
|
def test_config_command(self):
|
|
"""Test config command."""
|
|
runner = CliRunner()
|
|
|
|
with patch.dict(os.environ, {
|
|
"MQTT_BROKER_HOST": "test.broker.com",
|
|
"MQTT_BROKER_PORT": "1883",
|
|
"MQTT_CLIENT_ID": "test-client"
|
|
}):
|
|
result = runner.invoke(app, ["config"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Configuration Sources:" in result.stdout
|
|
assert "test.broker.com" in result.stdout
|
|
|
|
def test_health_command_connection_error(self):
|
|
"""Test health command with connection error."""
|
|
runner = CliRunner()
|
|
|
|
# Test with non-existent server
|
|
result = runner.invoke(app, ["health", "--host", "nonexistent", "--port", "9999"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "Cannot connect to server" in result.stdout
|
|
|
|
@patch('mcmqtt.main.MCMQTTServer')
|
|
def test_serve_command_basic(self, mock_server_class):
|
|
"""Test basic serve command."""
|
|
mock_server = MagicMock()
|
|
mock_server.run_server = MagicMock()
|
|
mock_server.initialize_mqtt_client = MagicMock()
|
|
mock_server.connect_mqtt = MagicMock()
|
|
mock_server.disconnect_mqtt = MagicMock()
|
|
mock_server_class.return_value = mock_server
|
|
|
|
runner = CliRunner()
|
|
|
|
# Mock asyncio.run to avoid actually starting server
|
|
with patch('asyncio.run') as mock_run:
|
|
result = runner.invoke(app, [
|
|
"serve",
|
|
"--host", "localhost",
|
|
"--port", "3000"
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
mock_server_class.assert_called_once()
|
|
|
|
def test_serve_command_with_mqtt_config(self):
|
|
"""Test serve command with MQTT configuration."""
|
|
runner = CliRunner()
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
result = runner.invoke(app, [
|
|
"serve",
|
|
"--mqtt-host", "localhost",
|
|
"--mqtt-port", "1883",
|
|
"--mqtt-client-id", "test-client"
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
|
|
def test_serve_command_with_auto_connect(self):
|
|
"""Test serve command with auto-connect."""
|
|
runner = CliRunner()
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
result = runner.invoke(app, [
|
|
"serve",
|
|
"--mqtt-host", "localhost",
|
|
"--auto-connect"
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
|
|
|
|
class TestConfigurationFunctions:
|
|
"""Test configuration utility functions."""
|
|
|
|
def test_get_version_default(self):
|
|
"""Test version function with default fallback."""
|
|
# Mock importlib.metadata to raise exception
|
|
with patch('mcmqtt.main.version', side_effect=Exception("No version")):
|
|
version = get_version()
|
|
assert version == "0.1.0"
|
|
|
|
def test_create_mqtt_config_from_env_complete(self):
|
|
"""Test creating MQTT config from complete environment."""
|
|
env_vars = {
|
|
"MQTT_BROKER_HOST": "test.broker.com",
|
|
"MQTT_BROKER_PORT": "1883",
|
|
"MQTT_CLIENT_ID": "test-client",
|
|
"MQTT_USERNAME": "testuser",
|
|
"MQTT_PASSWORD": "testpass",
|
|
"MQTT_KEEPALIVE": "30",
|
|
"MQTT_QOS": "2",
|
|
"MQTT_USE_TLS": "true",
|
|
"MQTT_CLEAN_SESSION": "false",
|
|
"MQTT_RECONNECT_INTERVAL": "10",
|
|
"MQTT_MAX_RECONNECT_ATTEMPTS": "5"
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars):
|
|
config = create_mqtt_config_from_env()
|
|
|
|
assert config is not None
|
|
assert config.broker_host == "test.broker.com"
|
|
assert config.broker_port == 1883
|
|
assert config.client_id == "test-client"
|
|
assert config.username == "testuser"
|
|
assert config.password == "testpass"
|
|
assert config.keepalive == 30
|
|
assert config.qos.value == 2
|
|
assert config.use_tls is True
|
|
assert config.clean_session is False
|
|
assert config.reconnect_interval == 10
|
|
assert config.max_reconnect_attempts == 5
|
|
|
|
def test_create_mqtt_config_from_env_minimal(self):
|
|
"""Test creating MQTT config with minimal environment."""
|
|
env_vars = {
|
|
"MQTT_BROKER_HOST": "minimal.broker.com"
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars):
|
|
config = create_mqtt_config_from_env()
|
|
|
|
assert config is not None
|
|
assert config.broker_host == "minimal.broker.com"
|
|
assert config.broker_port == 1883 # Default
|
|
assert config.client_id.startswith("mcmqtt-") # Generated
|
|
assert config.username is None
|
|
assert config.password is None
|
|
|
|
def test_create_mqtt_config_from_env_missing_host(self):
|
|
"""Test creating MQTT config without required host."""
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
config = create_mqtt_config_from_env()
|
|
|
|
assert config is None
|
|
|
|
def test_create_mqtt_config_from_env_invalid_values(self):
|
|
"""Test creating MQTT config with invalid values."""
|
|
env_vars = {
|
|
"MQTT_BROKER_HOST": "test.broker.com",
|
|
"MQTT_BROKER_PORT": "invalid_port",
|
|
"MQTT_QOS": "5" # Invalid QoS
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars):
|
|
config = create_mqtt_config_from_env()
|
|
|
|
# Should return None due to invalid values
|
|
assert config is None
|
|
|
|
|
|
class TestLogging:
|
|
"""Test logging configuration."""
|
|
|
|
def test_setup_logging_info_level(self):
|
|
"""Test logging setup with INFO level."""
|
|
from mcmqtt.main import setup_logging
|
|
|
|
setup_logging("INFO")
|
|
|
|
# Test that logger is configured
|
|
import logging
|
|
logger = logging.getLogger()
|
|
assert logger.level == logging.INFO
|
|
|
|
def test_setup_logging_debug_level(self):
|
|
"""Test logging setup with DEBUG level."""
|
|
from mcmqtt.main import setup_logging
|
|
|
|
setup_logging("DEBUG")
|
|
|
|
import logging
|
|
logger = logging.getLogger()
|
|
assert logger.level == logging.DEBUG
|
|
|
|
def test_setup_logging_invalid_level(self):
|
|
"""Test logging setup with invalid level."""
|
|
from mcmqtt.main import setup_logging
|
|
|
|
# Should not raise exception, will use default
|
|
setup_logging("INVALID")
|
|
|
|
|
|
class TestServerLifecycle:
|
|
"""Test server lifecycle management."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_startup_with_config(self):
|
|
"""Test server startup with MQTT configuration."""
|
|
from mcmqtt.main import MCMQTTServer
|
|
from mcmqtt.mqtt.types import MQTTConfig
|
|
|
|
config = MQTTConfig(
|
|
broker_host="localhost",
|
|
broker_port=1883,
|
|
client_id="test-startup"
|
|
)
|
|
|
|
server = MCMQTTServer(config)
|
|
|
|
# Test initialization
|
|
success = await server.initialize_mqtt_client()
|
|
assert success
|
|
|
|
# Test cleanup
|
|
await server.disconnect_mqtt()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_startup_without_config(self):
|
|
"""Test server startup without MQTT configuration."""
|
|
from mcmqtt.main import MCMQTTServer
|
|
|
|
server = MCMQTTServer()
|
|
|
|
# Should work without MQTT config
|
|
assert server.mcp is not None
|
|
|
|
def test_main_function_exists(self):
|
|
"""Test that main function exists and is callable."""
|
|
assert callable(main)
|
|
|
|
|
|
class TestErrorHandling:
|
|
"""Test error handling in CLI."""
|
|
|
|
def test_serve_with_invalid_port(self):
|
|
"""Test serve command with invalid port."""
|
|
runner = CliRunner()
|
|
|
|
result = runner.invoke(app, [
|
|
"serve",
|
|
"--port", "99999" # Invalid port number
|
|
])
|
|
|
|
# Should handle gracefully (typer validates port range)
|
|
# Actual behavior depends on typer validation
|
|
|
|
def test_serve_keyboard_interrupt(self):
|
|
"""Test graceful shutdown on keyboard interrupt."""
|
|
runner = CliRunner()
|
|
|
|
def mock_run_with_interrupt():
|
|
raise KeyboardInterrupt()
|
|
|
|
with patch('asyncio.run', side_effect=mock_run_with_interrupt):
|
|
result = runner.invoke(app, ["serve"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "stopped" in result.stdout.lower()
|
|
|
|
def test_health_command_invalid_response(self):
|
|
"""Test health command with invalid server response."""
|
|
runner = CliRunner()
|
|
|
|
# Mock httpx to return invalid response
|
|
with patch('httpx.get') as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 500
|
|
mock_get.return_value = mock_response
|
|
|
|
result = runner.invoke(app, ["health"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "unhealthy" in result.stdout
|
|
|
|
|
|
class TestEnvironmentHandling:
|
|
"""Test environment variable handling."""
|
|
|
|
def test_environment_variable_display(self):
|
|
"""Test environment variable display in config command."""
|
|
runner = CliRunner()
|
|
|
|
test_env = {
|
|
"MQTT_BROKER_HOST": "env.broker.com",
|
|
"MQTT_CLIENT_ID": "env-client",
|
|
"LOG_LEVEL": "DEBUG"
|
|
}
|
|
|
|
with patch.dict(os.environ, test_env):
|
|
result = runner.invoke(app, ["config"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "env.broker.com" in result.stdout
|
|
assert "env-client" in result.stdout
|
|
assert "DEBUG" in result.stdout
|
|
|
|
def test_password_masking_in_config(self):
|
|
"""Test that passwords are masked in config display."""
|
|
runner = CliRunner()
|
|
|
|
test_env = {
|
|
"MQTT_BROKER_HOST": "test.broker.com",
|
|
"MQTT_PASSWORD": "secret123"
|
|
}
|
|
|
|
with patch.dict(os.environ, test_env):
|
|
result = runner.invoke(app, ["config"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "secret123" not in result.stdout
|
|
assert "***" in result.stdout
|
|
|
|
|
|
class TestSignalHandling:
|
|
"""Test signal handling and graceful shutdown."""
|
|
|
|
@patch('mcmqtt.main.MCMQTTServer')
|
|
def test_graceful_shutdown_on_exception(self, mock_server_class):
|
|
"""Test graceful shutdown when server raises exception."""
|
|
mock_server = MagicMock()
|
|
mock_server_class.return_value = mock_server
|
|
|
|
# Mock server to raise exception
|
|
async def mock_run_server(*args):
|
|
raise Exception("Server error")
|
|
|
|
mock_server.run_server = mock_run_server
|
|
|
|
runner = CliRunner()
|
|
|
|
with patch('asyncio.run'):
|
|
result = runner.invoke(app, ["serve"])
|
|
|
|
# Should handle the exception gracefully
|
|
# Exit code depends on implementation
|
|
|
|
|
|
class TestBannerAndOutput:
|
|
"""Test startup banner and output formatting."""
|
|
|
|
@patch('mcmqtt.main.get_version')
|
|
def test_startup_banner_display(self, mock_version):
|
|
"""Test that startup banner is displayed correctly."""
|
|
mock_version.return_value = "1.0.0"
|
|
|
|
runner = CliRunner()
|
|
|
|
with patch('asyncio.run'):
|
|
result = runner.invoke(app, ["serve"])
|
|
|
|
assert "mcmqtt FastMCP MQTT Server v1.0.0" in result.stdout
|
|
|
|
def test_rich_console_output(self):
|
|
"""Test that rich console formatting works."""
|
|
from mcmqtt.main import console
|
|
from rich.console import Console
|
|
|
|
assert isinstance(console, Console)
|
|
|
|
def test_config_output_formatting(self):
|
|
"""Test config command output formatting."""
|
|
runner = CliRunner()
|
|
|
|
with patch.dict(os.environ, {"MQTT_BROKER_HOST": "test.com"}):
|
|
result = runner.invoke(app, ["config"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Configuration Sources:" in result.stdout
|
|
assert "Environment Variables:" in result.stdout |