#!/usr/bin/env python3 """ Tests for Scan Bridge Prefect Daemon ===================================== Unit and integration tests for the Prefect-managed scan bridge. """ import sys import time import json import signal import subprocess from datetime import datetime, timezone from pathlib import Path from unittest.mock import Mock, patch, MagicMock # Add paths sys.path.insert(0, '/mnt/dolphinng5_predict') sys.path.insert(0, '/mnt/dolphinng5_predict/prod') import pytest # Import module under test from scan_bridge_prefect_daemon import ( ScanBridgeProcess, check_hazelcast_data_freshness, perform_health_check, HEALTH_CHECK_INTERVAL, DATA_STALE_THRESHOLD, DATA_WARNING_THRESHOLD, ) # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture def mock_hazelcast_client(): """Mock Hazelcast client for testing.""" with patch('scan_bridge_prefect_daemon.hazelcast') as mock_hz: mock_client = MagicMock() mock_map = MagicMock() # Default: fresh data mock_data = { 'scan_number': 9999, 'file_mtime': time.time(), 'assets': ['BTCUSDT'] * 50, 'asset_prices': [70000.0] * 50, } mock_map.get.return_value = json.dumps(mock_data) mock_client.get_map.return_value.blocking.return_value = mock_map mock_hz.HazelcastClient.return_value = mock_client yield mock_hz @pytest.fixture def process_manager(): """Create a process manager instance.""" pm = ScanBridgeProcess() yield pm # Cleanup if pm.is_running(): pm.stop() # ============================================================================= # Test Class: ScanBridgeProcess # ============================================================================= class TestScanBridgeProcess: """Test the ScanBridgeProcess manager.""" def test_initialization(self, process_manager): """Test process manager initializes correctly.""" assert process_manager.process is None assert process_manager.start_time is None assert process_manager.restart_count == 0 assert not process_manager.is_running() def test_is_running_false_when_not_started(self, process_manager): """Test is_running returns False when process not started.""" assert not process_manager.is_running() def test_get_exit_code_none_when_not_started(self, process_manager): """Test get_exit_code returns None when process not started.""" assert process_manager.get_exit_code() is None @patch('scan_bridge_prefect_daemon.subprocess.Popen') def test_start_success(self, mock_popen, process_manager): """Test successful process start.""" mock_process = MagicMock() mock_process.poll.return_value = None # Still running mock_process.pid = 12345 mock_popen.return_value = mock_process with patch('scan_bridge_prefect_daemon.time.sleep'): result = process_manager.start() assert result is True assert process_manager.is_running() assert process_manager.process.pid == 12345 assert process_manager.start_time is not None mock_popen.assert_called_once() @patch('scan_bridge_prefect_daemon.subprocess.Popen') def test_start_failure_immediate_exit(self, mock_popen, process_manager): """Test start failure when process exits immediately.""" mock_process = MagicMock() mock_process.poll.return_value = 1 # Already exited with error mock_popen.return_value = mock_process with patch('scan_bridge_prefect_daemon.time.sleep'): result = process_manager.start() assert result is False assert not process_manager.is_running() @patch('scan_bridge_prefect_daemon.subprocess.Popen') def test_stop_graceful(self, mock_popen, process_manager): """Test graceful process stop.""" mock_process = MagicMock() mock_process.poll.return_value = None # Running mock_process.pid = 12345 mock_process.wait.return_value = None mock_popen.return_value = mock_process # Start first with patch('scan_bridge_prefect_daemon.time.sleep'): with patch('scan_bridge_prefect_daemon.threading.Thread'): process_manager.start() # Then stop process_manager.stop() mock_process.send_signal.assert_called_once_with(signal.SIGTERM) mock_process.wait.assert_called_once() @patch('scan_bridge_prefect_daemon.subprocess.Popen') def test_stop_force_kill(self, mock_popen, process_manager): """Test force kill when graceful stop fails.""" mock_process = MagicMock() mock_process.poll.return_value = None mock_process.pid = 12345 mock_process.wait.side_effect = subprocess.TimeoutExpired(cmd='test', timeout=10) mock_popen.return_value = mock_process # Start first with patch('scan_bridge_prefect_daemon.time.sleep'): with patch('scan_bridge_prefect_daemon.threading.Thread'): process_manager.start() # Stop (will timeout and force kill) process_manager.stop(timeout=1) mock_process.kill.assert_called_once() # ============================================================================= # Test Class: Hazelcast Data Freshness # ============================================================================= class TestHazelcastDataFreshness: """Test Hazelcast data freshness checking.""" @patch('scan_bridge_prefect_daemon.HAZELCAST_AVAILABLE', True) def test_fresh_data(self, mock_hazelcast_client): """Test detection of fresh data.""" result = check_hazelcast_data_freshness() assert result['available'] is True assert result['has_data'] is True assert result['scan_number'] == 9999 assert result['asset_count'] == 50 assert result['data_age_sec'] < 5 # Just created assert result['is_fresh'] is True assert result['is_warning'] is False @patch('scan_bridge_prefect_daemon.HAZELCAST_AVAILABLE', True) def test_stale_data(self, mock_hazelcast_client): """Test detection of stale data.""" # Mock old data old_time = time.time() - 120 # 2 minutes ago mock_data = { 'scan_number': 1000, 'file_mtime': old_time, 'assets': ['BTCUSDT'], } mock_hazelcast_client.HazelcastClient.return_value.get_map.return_value.blocking.return_value.get.return_value = json.dumps(mock_data) result = check_hazelcast_data_freshness() assert result['available'] is True assert result['has_data'] is True assert result['data_age_sec'] > DATA_STALE_THRESHOLD assert result['is_fresh'] is False @patch('scan_bridge_prefect_daemon.HAZELCAST_AVAILABLE', True) def test_warning_data(self, mock_hazelcast_client): """Test detection of warning-level data age.""" # Mock slightly old data warn_time = time.time() - 45 # 45 seconds ago mock_data = { 'scan_number': 1000, 'file_mtime': warn_time, 'assets': ['BTCUSDT'], } mock_hazelcast_client.HazelcastClient.return_value.get_map.return_value.blocking.return_value.get.return_value = json.dumps(mock_data) result = check_hazelcast_data_freshness() assert result['available'] is True assert result['data_age_sec'] > DATA_WARNING_THRESHOLD assert result['is_warning'] is True assert result['is_fresh'] is True # Not yet stale @patch('scan_bridge_prefect_daemon.HAZELCAST_AVAILABLE', True) def test_no_data_in_hz(self, mock_hazelcast_client): """Test when no data exists in Hazelcast.""" mock_hazelcast_client.HazelcastClient.return_value.get_map.return_value.blocking.return_value.get.return_value = None result = check_hazelcast_data_freshness() assert result['available'] is True assert result['has_data'] is False assert 'error' in result def test_hazelcast_not_available(self): """Test when Hazelcast module not available.""" with patch('scan_bridge_prefect_daemon.HAZELCAST_AVAILABLE', False): result = check_hazelcast_data_freshness() assert result['available'] is False assert 'error' in result @patch('scan_bridge_prefect_daemon.HAZELCAST_AVAILABLE', True) def test_hazelcast_connection_error(self, mock_hazelcast_client): """Test handling of Hazelcast connection error.""" mock_hazelcast_client.HazelcastClient.side_effect = Exception("Connection refused") result = check_hazelcast_data_freshness() assert result['available'] is True # Module available assert result['has_data'] is False assert 'error' in result # ============================================================================= # Test Class: Health Check Task # ============================================================================= class TestPerformHealthCheck: """Test the perform_health_check Prefect task.""" @patch('scan_bridge_prefect_daemon.get_run_logger') @patch('scan_bridge_prefect_daemon.HAZELCAST_AVAILABLE', True) def test_healthy_state(self, mock_logger, mock_hazelcast_client): """Test health check with healthy system.""" # Mock running process with patch('scan_bridge_prefect_daemon.bridge_process') as mock_pm: mock_pm.is_running.return_value = True mock_pm.process = MagicMock() mock_pm.process.pid = 12345 mock_pm.start_time = datetime.now(timezone.utc) result = perform_health_check() assert result['healthy'] is True assert result['process_running'] is True assert result['action_required'] is None @patch('scan_bridge_prefect_daemon.get_run_logger') def test_process_not_running(self, mock_logger): """Test health check when process not running.""" with patch('scan_bridge_prefect_daemon.bridge_process') as mock_pm: mock_pm.is_running.return_value = False result = perform_health_check() assert result['healthy'] is False assert result['process_running'] is False assert result['action_required'] == 'restart' @patch('scan_bridge_prefect_daemon.get_run_logger') @patch('scan_bridge_prefect_daemon.HAZELCAST_AVAILABLE', True) def test_stale_data_triggers_restart(self, mock_logger, mock_hazelcast_client): """Test that stale data triggers restart action.""" # Mock old data old_time = time.time() - 120 mock_data = { 'scan_number': 1000, 'file_mtime': old_time, 'assets': ['BTCUSDT'], } mock_hazelcast_client.HazelcastClient.return_value.get_map.return_value.blocking.return_value.get.return_value = json.dumps(mock_data) with patch('scan_bridge_prefect_daemon.bridge_process') as mock_pm: mock_pm.is_running.return_value = True mock_pm.process = MagicMock() mock_pm.process.pid = 12345 mock_pm.start_time = datetime.now(timezone.utc) result = perform_health_check() assert result['healthy'] is False assert result['action_required'] == 'restart' # ============================================================================= # Test Class: Integration Tests # ============================================================================= @pytest.mark.integration class TestIntegration: """Integration tests requiring real infrastructure.""" def test_real_hazelcast_connection(self): """Test with real Hazelcast (if available).""" try: import hazelcast client = hazelcast.HazelcastClient( cluster_name="dolphin", cluster_members=["127.0.0.1:5701"], ) # Check if we can get data features_map = client.get_map('DOLPHIN_FEATURES').blocking() val = features_map.get('latest_eigen_scan') client.shutdown() if val: data = json.loads(val) print(f"\n✓ Real Hz: Scan #{data.get('scan_number')}, {len(data.get('assets', []))} assets") else: print("\n⚠ Real Hz connected but no data") except Exception as e: pytest.skip(f"Hazelcast not available: {e}") def test_real_process_lifecycle(self): """Test actual process start/stop (if script exists).""" script_path = Path('/mnt/dolphinng5_predict/prod/scan_bridge_service.py') if not script_path.exists(): pytest.skip("scan_bridge_service.py not found") # Don't actually start the real bridge in tests # Just verify the script exists and is valid Python result = subprocess.run( [sys.executable, '-m', 'py_compile', str(script_path)], capture_output=True ) assert result.returncode == 0, "Script has syntax errors" print("\n✓ Script syntax valid") # ============================================================================= # Test Runner # ============================================================================= if __name__ == '__main__': print("=" * 70) print("🧪 Scan Bridge Prefect Daemon Tests") print("=" * 70) # Run with pytest exit_code = pytest.main([ __file__, '-v', '--tb=short', '-k', 'not integration' # Skip integration by default ]) sys.exit(exit_code)