diff --git a/tests/conftest.py b/tests/conftest.py index 8b9b885..937cffc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ from meshcore_api.database.models import Base from meshcore_api.meshcore.mock import MockMeshCore from meshcore_api.queue.manager import CommandQueueManager +from meshcore_api.queue.models import QueueFullBehavior from meshcore_api.subscriber.event_handler import EventHandler from meshcore_api.webhook.handler import WebhookHandler @@ -74,7 +75,7 @@ def test_config(temp_db_path: str) -> Config: webhook_advertisement_jsonpath="$", # Queue queue_max_size=100, - queue_full_behavior="reject", + queue_full_behavior=QueueFullBehavior.REJECT, # Rate limiting (disabled for fast tests) rate_limit_enabled=False, rate_limit_per_second=10.0, @@ -100,7 +101,7 @@ def test_config_with_auth(test_config: Config) -> Config: def db_engine(test_config: Config) -> Generator[DatabaseEngine, None, None]: """Create a database engine for testing.""" engine = DatabaseEngine(test_config.db_path) - engine.init_db() + engine.initialize() yield engine engine.close() @@ -135,7 +136,15 @@ async def queue_manager( """Create a CommandQueueManager for testing.""" manager = CommandQueueManager( meshcore=mock_meshcore, - config=test_config, + max_queue_size=test_config.queue_max_size, + queue_full_behavior=test_config.queue_full_behavior, + rate_limit_per_second=test_config.rate_limit_per_second, + rate_limit_burst=test_config.rate_limit_burst, + rate_limit_enabled=test_config.rate_limit_enabled, + debounce_window_seconds=test_config.debounce_window_seconds, + debounce_cache_max_size=test_config.debounce_cache_max_size, + debounce_enabled=test_config.debounce_enabled, + debounce_commands=set(test_config.debounce_commands.split(",")), ) await manager.start() yield manager @@ -169,36 +178,16 @@ async def event_handler( @pytest.fixture(scope="function") -def test_app( - test_config: Config, - db_engine: DatabaseEngine, - mock_meshcore: MockMeshCore, - queue_manager: CommandQueueManager, -) -> TestClient: - """Create a FastAPI test client.""" - app = create_app( - config=test_config, - db_engine=db_engine, - meshcore=mock_meshcore, - queue_manager=queue_manager, - ) +def test_app() -> TestClient: + """Create a simple FastAPI test client without dependencies.""" + app = create_app() return TestClient(app) @pytest.fixture(scope="function") -def test_app_with_auth( - test_config_with_auth: Config, - db_engine: DatabaseEngine, - mock_meshcore: MockMeshCore, - queue_manager: CommandQueueManager, -) -> TestClient: - """Create a FastAPI test client with authentication.""" - app = create_app( - config=test_config_with_auth, - db_engine=db_engine, - meshcore=mock_meshcore, - queue_manager=queue_manager, - ) +def test_app_with_auth() -> TestClient: + """Create a simple FastAPI test client with authentication.""" + app = create_app(bearer_token="test-token-12345") return TestClient(app) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..12d1638 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,195 @@ +"""Unit tests for configuration module - fixed to match actual implementation.""" + +import pytest + +from meshcore_api.config import Config + + +class TestConfig: + """Test configuration parsing and validation.""" + + def test_default_config_values(self): + """Test default configuration values.""" + config = Config() + + # Connection defaults + assert config.serial_port == "/dev/ttyUSB0" + assert config.serial_baud == 115200 + assert config.use_mock is False + assert config.mock_scenario is None + assert config.mock_loop is False + assert config.mock_nodes == 10 + assert config.mock_min_interval == 1.0 + assert config.mock_max_interval == 10.0 + assert config.mock_center_lat == 45.5231 + assert config.mock_center_lon == -122.6765 + + # Database defaults + assert config.db_path == "./data/meshcore.db" + assert config.retention_days == 30 + assert config.cleanup_interval_hours == 1 + + # API defaults + assert config.api_host == "0.0.0.0" + assert config.api_port == 8000 + assert config.api_title == "MeshCore API" + assert config.api_version == "1.0.0" + assert config.api_bearer_token is None + + # Other defaults + assert config.metrics_enabled is True + assert config.enable_write is True + assert config.log_level == "INFO" + assert config.log_format == "json" + + # Webhook defaults + assert config.webhook_message_direct is None + assert config.webhook_message_channel is None + assert config.webhook_advertisement is None + + def test_config_custom_values(self): + """Test configuration with custom values.""" + config = Config( + serial_port="/dev/ttyACM0", + use_mock=True, + mock_nodes=20, + api_port=9000, + db_path="/tmp/test.db", + log_level="DEBUG" + ) + + assert config.serial_port == "/dev/ttyACM0" + assert config.use_mock is True + assert config.mock_nodes == 20 + assert config.api_port == 9000 + assert config.db_path == "/tmp/test.db" + assert config.log_level == "DEBUG" + + def test_config_webhook_configuration(self): + """Test webhook configuration.""" + webhook_url = "http://localhost:9000/webhook" + config = Config( + webhook_message_direct=webhook_url, + webhook_message_channel=webhook_url, + webhook_advertisement=webhook_url + ) + + assert config.webhook_message_direct == webhook_url + assert config.webhook_message_channel == webhook_url + assert config.webhook_advertisement == webhook_url + + def test_config_mock_settings(self): + """Test mock configuration settings.""" + config = Config( + mock_scenario="test_scenario", + mock_loop=True, + mock_nodes=5, + mock_min_interval=0.5, + mock_max_interval=2.0, + mock_center_lat=40.7128, + mock_center_lon=-74.0060 + ) + + assert config.mock_scenario == "test_scenario" + assert config.mock_loop is True + assert config.mock_nodes == 5 + assert config.mock_min_interval == 0.5 + assert config.mock_max_interval == 2.0 + assert config.mock_center_lat == 40.7128 + assert config.mock_center_lon == -74.0060 + + def test_config_database_settings(self): + """Test database configuration settings.""" + config = Config( + db_path="/custom/path/database.db", + retention_days=90, + cleanup_interval_hours=24 + ) + + assert config.db_path == "/custom/path/database.db" + assert config.retention_days == 90 + assert config.cleanup_interval_hours == 24 + + def test_config_api_settings(self): + """Test API configuration settings.""" + config = Config( + api_host="127.0.0.1", + api_port=8080, + api_title="Custom API", + api_version="2.0.0", + api_bearer_token="secret-token" + ) + + assert config.api_host == "127.0.0.1" + assert config.api_port == 8080 + assert config.api_title == "Custom API" + assert config.api_version == "2.0.0" + assert config.api_bearer_token == "secret-token" + + def test_config_other_settings(self): + """Test other configuration settings.""" + config = Config( + metrics_enabled=False, + enable_write=False, + log_level="ERROR", + log_format="text" + ) + + assert config.metrics_enabled is False + assert config.enable_write is False + assert config.log_level == "ERROR" + assert config.log_format == "text" + + def test_config_dataclass_behavior(self): + """Test that Config behaves as a dataclass.""" + config = Config(api_port=9000) + + # Test equality + config2 = Config(api_port=9000) + assert config == config2 + + # Test inequality + config3 = Config(api_port=8000) + assert config != config3 + + # Test string representation + config_str = str(config) + assert "Config" in config_str + assert "api_port=9000" in config_str + + def test_config_optional_fields(self): + """Test optional field handling.""" + config = Config() + + # None values for optional fields + assert config.mock_scenario is None + assert config.api_bearer_token is None + assert config.webhook_message_direct is None + assert config.webhook_message_channel is None + assert config.webhook_advertisement is None + + def test_config_type_hints(self): + """Test that config values have correct types.""" + config = Config() + + # Type assertions + assert isinstance(config.serial_port, str) + assert isinstance(config.serial_baud, int) + assert isinstance(config.use_mock, bool) + assert isinstance(config.mock_nodes, int) + assert isinstance(config.mock_min_interval, float) + assert isinstance(config.mock_max_interval, float) + assert isinstance(config.db_path, str) + assert isinstance(config.retention_days, int) + assert isinstance(config.api_port, int) + assert isinstance(config.log_level, str) + + def test_config_immutability_of_defaults(self): + """Test that default values are appropriate.""" + config1 = Config() + config2 = Config() + + # Ensure default values are consistent + assert config1.serial_port == config2.serial_port + assert config1.api_port == config2.api_port + assert config1.use_mock == config2.use_mock \ No newline at end of file diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py new file mode 100644 index 0000000..2897414 --- /dev/null +++ b/tests/unit/test_constants.py @@ -0,0 +1,132 @@ +"""Unit tests for constants module - fixed to match actual implementation.""" + +import pytest + +from meshcore_api import constants as const + + +class TestConstants: + """Test application constants.""" + + def test_node_type_map_structure(self): + """Test that NODE_TYPE_MAP has correct structure.""" + assert hasattr(const, 'NODE_TYPE_MAP') + assert isinstance(const.NODE_TYPE_MAP, dict) + + # Check that it contains expected node types + expected_keys = [0, 1, 2] + for key in expected_keys: + assert key in const.NODE_TYPE_MAP + + # Check that values are strings + for value in const.NODE_TYPE_MAP.values(): + assert isinstance(value, str) + + def test_node_type_map_values(self): + """Test specific values in NODE_TYPE_MAP.""" + assert const.NODE_TYPE_MAP[0] == "unknown" + assert const.NODE_TYPE_MAP[1] == "cli" + assert const.NODE_TYPE_MAP[2] == "rep" + + def test_node_type_name_with_valid_numbers(self): + """Test node_type_name function with valid numeric inputs.""" + assert const.node_type_name(0) == "unknown" + assert const.node_type_name(1) == "cli" + assert const.node_type_name(2) == "rep" + + def test_node_type_name_with_invalid_numbers(self): + """Test node_type_name function with invalid numeric inputs.""" + assert const.node_type_name(3) == "unknown" + assert const.node_type_name(999) == "unknown" + assert const.node_type_name(-1) == "unknown" + + def test_node_type_name_with_string_numbers(self): + """Test node_type_name function with string representations of numbers.""" + assert const.node_type_name("0") == "unknown" + assert const.node_type_name("1") == "cli" + assert const.node_type_name("2") == "rep" + + def test_node_type_name_with_invalid_string_numbers(self): + """Test node_type_name function with invalid string numbers.""" + assert const.node_type_name("3") == "unknown" + assert const.node_type_name("999") == "unknown" + assert const.node_type_name("-1") == "unknown" + + def test_node_type_name_with_valid_string_types(self): + """Test node_type_name function with valid string type names.""" + assert const.node_type_name("unknown") == "unknown" + assert const.node_type_name("cli") == "cli" + assert const.node_type_name("rep") == "rep" + + def test_node_type_name_case_insensitive_strings(self): + """Test node_type_name function with case insensitive string inputs.""" + assert const.node_type_name("UNKNOWN") == "unknown" + assert const.node_type_name("CLI") == "cli" + assert const.node_type_name("REP") == "rep" + assert const.node_type_name("Cli") == "cli" + assert const.node_type_name("ReP") == "rep" + + def test_node_type_name_with_whitespace(self): + """Test node_type_name function handles whitespace correctly.""" + assert const.node_type_name(" cli ") == "cli" + assert const.node_type_name("\trep\n") == "rep" + + def test_node_type_name_with_invalid_strings(self): + """Test node_type_name function with invalid string inputs.""" + assert const.node_type_name("invalid") == "unknown" + assert const.node_type_name("not_a_type") == "unknown" + assert const.node_type_name("random") == "unknown" + + def test_node_type_name_with_none_input(self): + """Test node_type_name function with None input.""" + assert const.node_type_name(None) == "unknown" + + def test_node_type_name_with_non_numeric_strings(self): + """Test node_type_name function with non-numeric strings.""" + assert const.node_type_name("abc") == "unknown" + assert const.node_type_name("12.34") == "unknown" + assert const.node_type_name("1abc") == "unknown" + + def test_node_type_name_with_edge_cases(self): + """Test node_type_name function with edge cases.""" + # Empty string + assert const.node_type_name("") == "unknown" + + # Float values (int() conversion truncates) + assert const.node_type_name(1.0) == "cli" + assert const.node_type_name(2.5) == "rep" # int(2.5) == 2 + assert const.node_type_name(3.7) == "unknown" # int(3.7) == 3 + + # Boolean values + assert const.node_type_name(True) == "cli" # True is 1 + assert const.node_type_name(False) == "unknown" # False is 0 + + def test_node_type_map_immutability(self): + """Test that NODE_TYPE_MAP is not accidentally modified.""" + original_map = const.NODE_TYPE_MAP.copy() + + # The function should not modify the global map + const.node_type_name("test") + + assert const.NODE_TYPE_MAP == original_map + + def test_node_type_function_consistency(self): + """Test that node_type_name is consistent across calls.""" + test_inputs = [0, 1, 2, "0", "1", "2", "cli", "rep", "unknown"] + + for input_val in test_inputs: + result1 = const.node_type_name(input_val) + result2 = const.node_type_name(input_val) + assert result1 == result2 + + def test_node_type_comprehensive_coverage(self): + """Test that all node type mappings are covered.""" + # Test that all keys in NODE_TYPE_MAP work + for key, expected_value in const.NODE_TYPE_MAP.items(): + result = const.node_type_name(key) + assert result == expected_value + + # Test that all values in NODE_TYPE_MAP work when passed as strings + for expected_value in const.NODE_TYPE_MAP.values(): + result = const.node_type_name(expected_value) + assert result == expected_value \ No newline at end of file diff --git a/tests/unit/test_database_cleanup.py b/tests/unit/test_database_cleanup.py new file mode 100644 index 0000000..812a9a6 --- /dev/null +++ b/tests/unit/test_database_cleanup.py @@ -0,0 +1,128 @@ +"""Unit tests for database cleanup functionality.""" + +from unittest.mock import Mock, patch + +import pytest + +from meshcore_api.database.cleanup import DataCleanup + + +class TestDataCleanup: + """Test database cleanup operations.""" + + def test_cleanup_initialization(self): + """Test DataCleanup initialization.""" + retention_days = 30 + cleanup = DataCleanup(retention_days=retention_days) + + assert cleanup.retention_days == retention_days + + def test_cleanup_initialization_custom_retention(self): + """Test DataCleanup initialization with custom retention.""" + retention_days = 45 + cleanup = DataCleanup(retention_days=retention_days) + + assert cleanup.retention_days == 45 + + @patch('meshcore_api.database.cleanup.session_scope') + @patch('meshcore_api.database.cleanup.delete') + @patch('meshcore_api.database.cleanup.logger') + def test_cleanup_old_data(self, mock_logger, mock_delete, mock_session_scope): + """Test cleanup old data functionality.""" + # Setup mocks + mock_session = Mock() + mock_session_scope.return_value.__enter__.return_value = mock_session + mock_session_scope.return_value.__exit__.return_value = None + + mock_result = Mock() + mock_result.rowcount = 100 + mock_session.execute.return_value = mock_result + + # Create cleanup instance + cleanup = DataCleanup(retention_days=30) + + # Run cleanup + result = cleanup.cleanup_old_data() + + # Verify result structure + assert "messages" in result + assert "advertisements" in result + assert "telemetry" in result + assert "trace_paths" in result + assert "events_log" in result + + # Verify all delete operations were called + assert mock_session.execute.call_count == 5 + assert mock_delete.call_count == 5 + + # Verify logger was called + mock_logger.info.assert_called() + + @patch('meshcore_api.database.cleanup.session_scope') + def test_cleanup_with_different_retention_days(self, mock_session_scope): + """Test cleanup with different retention periods.""" + mock_session = Mock() + mock_session_scope.return_value.__enter__.return_value = mock_session + mock_session_scope.return_value.__exit__.return_value = None + + mock_result = Mock() + mock_result.rowcount = 50 + mock_session.execute.return_value = mock_result + + # Test with 7 days retention + cleanup = DataCleanup(retention_days=7) + result = cleanup.cleanup_old_data() + + assert cleanup.retention_days == 7 + assert isinstance(result, dict) + + @patch('meshcore_api.database.cleanup.session_scope') + @patch('meshcore_api.database.cleanup.logger') + def test_cleanup_logging(self, mock_logger, mock_session_scope): + """Test cleanup logging functionality.""" + mock_session = Mock() + mock_session_scope.return_value.__enter__.return_value = mock_session + mock_session_scope.return_value.__exit__.return_value = None + + mock_result = Mock() + mock_result.rowcount = 25 + mock_session.execute.return_value = mock_result + + cleanup = DataCleanup(retention_days=30) + cleanup.cleanup_old_data() + + # Verify logging calls + assert mock_logger.info.call_count >= 2 # Start and completion messages + mock_logger.debug.assert_called_once() # Detailed breakdown + + def test_cleanup_retention_validation(self): + """Test cleanup retention period validation.""" + # Test various valid retention periods + valid_periods = [1, 7, 30, 90, 365] + + for period in valid_periods: + cleanup = DataCleanup(retention_days=period) + assert cleanup.retention_days == period + + @patch('meshcore_api.database.cleanup.session_scope') + def test_cleanup_result_counts(self, mock_session_scope): + """Test cleanup returns correct deletion counts.""" + mock_session = Mock() + mock_session_scope.return_value.__enter__.return_value = mock_session + mock_session_scope.return_value.__exit__.return_value = None + + # Mock different deletion counts for each table + mock_results = [10, 5, 15, 8, 12] # Different row counts + mock_session.execute.side_effect = [Mock(rowcount=count) for count in mock_results] + + cleanup = DataCleanup(retention_days=30) + result = cleanup.cleanup_old_data() + + # Verify all expected keys are present + expected_keys = ["messages", "advertisements", "telemetry", "trace_paths", "events_log"] + for key in expected_keys: + assert key in result + + # Verify total calculation + expected_total = sum(mock_results) + assert sum(result.values()) == expected_total \ No newline at end of file