Files
DOLPHIN/prod/tests/test_scan_bridge_prefect_daemon.py

377 lines
14 KiB
Python
Raw Normal View History

#!/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)