OpenHands/enterprise/tests/unit/test_maintenance_task_runner_standalone.py
2025-09-04 15:44:54 -04:00

722 lines
25 KiB
Python

"""
Standalone tests for the MaintenanceTaskRunner.
These tests work without OpenHands dependencies and focus on testing the core
logic and behavior of the task runner using comprehensive mocking.
To run these tests in an environment with OpenHands dependencies:
1. Ensure OpenHands is available in the Python path
2. Run: python -m pytest tests/unit/test_maintenance_task_runner_standalone.py -v
"""
import asyncio
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
import pytest
class TestMaintenanceTaskRunnerStandalone:
"""Standalone tests for MaintenanceTaskRunner without OpenHands dependencies."""
def test_runner_initialization(self):
"""Test MaintenanceTaskRunner initialization."""
# Mock the runner class structure
class MockMaintenanceTaskRunner:
def __init__(self):
self._running = False
self._task = None
runner = MockMaintenanceTaskRunner()
assert runner._running is False
assert runner._task is None
@pytest.mark.asyncio
async def test_start_stop_lifecycle(self):
"""Test the start/stop lifecycle of the runner."""
# Mock the runner behavior
class MockMaintenanceTaskRunner:
def __init__(self):
self._running: bool = False
self._task = None
self.start_called = False
self.stop_called = False
async def start(self):
if self._running:
return
self._running = True
self._task = MagicMock() # Mock asyncio.Task
self.start_called = True
async def stop(self):
if not self._running:
return
self._running = False
if self._task:
self._task.cancel()
# Simulate awaiting the cancelled task
self.stop_called = True
runner = MockMaintenanceTaskRunner()
# Test start
await runner.start()
assert runner._running is True
assert runner.start_called is True
assert runner._task is not None
# Test start when already running (should be no-op)
runner.start_called = False
await runner.start()
assert runner.start_called is False # Should not be called again
# Test stop
await runner.stop()
running: bool = runner._running
assert running is False
assert runner.stop_called is True
# Test stop when not running (should be no-op)
runner.stop_called = False
await runner.stop()
assert runner.stop_called is False # Should not be called again
@pytest.mark.asyncio
async def test_run_loop_behavior(self):
"""Test the main run loop behavior."""
# Mock the run loop logic
class MockMaintenanceTaskRunner:
def __init__(self):
self._running = False
self.process_calls = 0
self.sleep_calls = 0
async def _run_loop(self):
loop_count = 0
while self._running and loop_count < 3: # Limit for testing
try:
await self._process_pending_tasks()
self.process_calls += 1
except Exception:
pass
try:
await asyncio.sleep(0.01) # Short sleep for testing
self.sleep_calls += 1
except asyncio.CancelledError:
break
loop_count += 1
async def _process_pending_tasks(self):
# Mock processing
pass
runner = MockMaintenanceTaskRunner()
runner._running = True
# Run the loop
await runner._run_loop()
# Verify the loop ran and called process_pending_tasks
assert runner.process_calls == 3
assert runner.sleep_calls == 3
@pytest.mark.asyncio
async def test_run_loop_error_handling(self):
"""Test error handling in the run loop."""
class MockMaintenanceTaskRunner:
def __init__(self):
self._running = False
self.error_count = 0
self.process_calls = 0
self.attempt_count = 0
async def _run_loop(self):
loop_count = 0
while self._running and loop_count < 2: # Limit for testing
try:
await self._process_pending_tasks()
self.process_calls += 1
except Exception:
self.error_count += 1
# Simulate logging the error
try:
await asyncio.sleep(0.01) # Short sleep for testing
except asyncio.CancelledError:
break
loop_count += 1
async def _process_pending_tasks(self):
self.attempt_count += 1
# Only fail on the first attempt
if self.attempt_count == 1:
raise Exception('Simulated processing error')
# Subsequent calls succeed
runner = MockMaintenanceTaskRunner()
runner._running = True
# Run the loop
await runner._run_loop()
# Verify error was handled and loop continued
assert runner.error_count == 1
assert runner.process_calls == 1 # First failed, second succeeded
assert runner.attempt_count == 2 # Two attempts were made
def test_pending_task_query_logic(self):
"""Test the logic for finding pending tasks."""
def find_pending_tasks(all_tasks, current_time):
"""Simulate the database query logic."""
pending_tasks = []
for task in all_tasks:
if task['status'] == 'PENDING' and task['start_at'] <= current_time:
pending_tasks.append(task)
return pending_tasks
now = datetime.now()
past_time = now - timedelta(minutes=5)
future_time = now + timedelta(minutes=5)
# Mock tasks with different statuses and start times
all_tasks = [
{'id': 1, 'status': 'PENDING', 'start_at': past_time}, # Should be selected
{'id': 2, 'status': 'PENDING', 'start_at': now}, # Should be selected
{
'id': 3,
'status': 'PENDING',
'start_at': future_time,
}, # Should NOT be selected (future)
{
'id': 4,
'status': 'WORKING',
'start_at': past_time,
}, # Should NOT be selected (working)
{
'id': 5,
'status': 'COMPLETED',
'start_at': past_time,
}, # Should NOT be selected (completed)
{
'id': 6,
'status': 'ERROR',
'start_at': past_time,
}, # Should NOT be selected (error)
{
'id': 7,
'status': 'INACTIVE',
'start_at': past_time,
}, # Should NOT be selected (inactive)
]
pending_tasks = find_pending_tasks(all_tasks, now)
# Should only return tasks 1 and 2
assert len(pending_tasks) == 2
assert pending_tasks[0]['id'] == 1
assert pending_tasks[1]['id'] == 2
@pytest.mark.asyncio
async def test_task_processing_success(self):
"""Test successful task processing."""
# Mock task processing logic
class MockTask:
def __init__(self, task_id, processor_type):
self.id = task_id
self.processor_type = processor_type
self.status = 'PENDING'
self.info = None
self.updated_at = None
def get_processor(self):
# Mock processor
processor = AsyncMock()
processor.return_value = {'result': 'success', 'processed_items': 5}
return processor
class MockMaintenanceTaskRunner:
def __init__(self):
self.status_updates = []
self.commits = []
async def _process_task(self, task):
# Simulate updating status to WORKING
task.status = 'WORKING'
task.updated_at = datetime.now()
self.status_updates.append(('WORKING', task.id))
self.commits.append('working_commit')
try:
# Get and execute processor
processor = task.get_processor()
result = await processor(task)
# Mark as completed
task.status = 'COMPLETED'
task.info = result
task.updated_at = datetime.now()
self.status_updates.append(('COMPLETED', task.id))
self.commits.append('completed_commit')
return result
except Exception as e:
# Handle error (not expected in this test)
task.status = 'ERROR'
task.info = {'error': str(e)}
self.status_updates.append(('ERROR', task.id))
self.commits.append('error_commit')
raise
runner = MockMaintenanceTaskRunner()
task = MockTask(123, 'test_processor')
# Process the task
result = await runner._process_task(task)
# Verify the processing flow
assert len(runner.status_updates) == 2
assert runner.status_updates[0] == ('WORKING', 123)
assert runner.status_updates[1] == ('COMPLETED', 123)
assert len(runner.commits) == 2
assert task.status == 'COMPLETED'
assert task.info == {'result': 'success', 'processed_items': 5}
assert result == {'result': 'success', 'processed_items': 5}
@pytest.mark.asyncio
async def test_task_processing_failure(self):
"""Test task processing with failure."""
class MockTask:
def __init__(self, task_id, processor_type):
self.id = task_id
self.processor_type = processor_type
self.status = 'PENDING'
self.info = None
self.updated_at = None
def get_processor(self):
# Mock processor that fails
processor = AsyncMock()
processor.side_effect = ValueError('Processing failed')
return processor
class MockMaintenanceTaskRunner:
def __init__(self):
self.status_updates = []
self.error_logged = None
async def _process_task(self, task):
# Simulate updating status to WORKING
task.status = 'WORKING'
task.updated_at = datetime.now()
self.status_updates.append(('WORKING', task.id))
try:
# Get and execute processor
processor = task.get_processor()
result = await processor(task)
# This shouldn't be reached
task.status = 'COMPLETED'
task.info = result
self.status_updates.append(('COMPLETED', task.id))
except Exception as e:
# Handle error
error_info = {
'error': str(e),
'error_type': type(e).__name__,
'processor_type': task.processor_type,
}
task.status = 'ERROR'
task.info = error_info
task.updated_at = datetime.now()
self.status_updates.append(('ERROR', task.id))
self.error_logged = error_info
runner = MockMaintenanceTaskRunner()
task = MockTask(456, 'failing_processor')
# Process the task
await runner._process_task(task)
# Verify the error handling flow
assert len(runner.status_updates) == 2
assert runner.status_updates[0] == ('WORKING', 456)
assert runner.status_updates[1] == ('ERROR', 456)
assert task.status == 'ERROR'
info = task.info
assert info is not None
assert info['error'] == 'Processing failed'
assert info['error_type'] == 'ValueError'
assert info['processor_type'] == 'failing_processor'
assert runner.error_logged is not None
def test_database_session_handling_pattern(self):
"""Test the database session handling pattern."""
# Mock the session handling logic
class MockSession:
def __init__(self):
self.queries = []
self.merges = []
self.commits = []
self.closed = False
def query(self, model):
self.queries.append(model)
return self
def filter(self, *conditions):
return self
def all(self):
return [] # Return empty list for testing
def merge(self, obj):
self.merges.append(obj)
return obj
def commit(self):
self.commits.append(datetime.now())
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.closed = True
def mock_session_maker():
return MockSession()
# Simulate the session usage pattern
def process_pending_tasks_pattern():
with mock_session_maker() as session:
# Query for pending tasks
pending_tasks = session.query('MaintenanceTask').filter().all()
return session, pending_tasks
def process_task_pattern(task):
# Update to WORKING
with mock_session_maker() as session:
task = session.merge(task)
session.commit()
working_session = session
# Update to COMPLETED/ERROR
with mock_session_maker() as session:
task = session.merge(task)
session.commit()
final_session = session
return working_session, final_session
# Test the patterns
query_session, tasks = process_pending_tasks_pattern()
assert len(query_session.queries) == 1
assert query_session.closed is True
mock_task = {'id': 1}
working_session, final_session = process_task_pattern(mock_task)
assert len(working_session.merges) == 1
assert len(working_session.commits) == 1
assert len(final_session.merges) == 1
assert len(final_session.commits) == 1
assert working_session.closed is True
assert final_session.closed is True
def test_logging_structure(self):
"""Test the structure of logging calls that would be made."""
log_calls = []
def mock_logger_info(message, extra=None):
log_calls.append({'level': 'info', 'message': message, 'extra': extra})
def mock_logger_error(message, extra=None):
log_calls.append({'level': 'error', 'message': message, 'extra': extra})
# Simulate the logging that would happen in the runner
def simulate_runner_logging():
# Start logging
mock_logger_info('maintenance_task_runner:started')
# Found pending tasks
mock_logger_info(
'maintenance_task_runner:found_pending_tasks', extra={'count': 3}
)
# Processing task
mock_logger_info(
'maintenance_task_runner:processing_task',
extra={'task_id': 123, 'processor_type': 'test_processor'},
)
# Task completed
mock_logger_info(
'maintenance_task_runner:task_completed',
extra={
'task_id': 123,
'processor_type': 'test_processor',
'info': {'result': 'success'},
},
)
# Task failed
mock_logger_error(
'maintenance_task_runner:task_failed',
extra={
'task_id': 456,
'processor_type': 'failing_processor',
'error': 'Processing failed',
'error_type': 'ValueError',
},
)
# Loop error
mock_logger_error(
'maintenance_task_runner:loop_error',
extra={'error': 'Database connection failed'},
)
# Stop logging
mock_logger_info('maintenance_task_runner:stopped')
# Run the simulation
simulate_runner_logging()
# Verify logging structure
assert len(log_calls) == 7
# Check start log
start_log = log_calls[0]
assert start_log['level'] == 'info'
assert 'started' in start_log['message']
assert start_log['extra'] is None
# Check found tasks log
found_log = log_calls[1]
assert 'found_pending_tasks' in found_log['message']
assert found_log['extra']['count'] == 3
# Check processing log
processing_log = log_calls[2]
assert 'processing_task' in processing_log['message']
assert processing_log['extra']['task_id'] == 123
assert processing_log['extra']['processor_type'] == 'test_processor'
# Check completed log
completed_log = log_calls[3]
assert 'task_completed' in completed_log['message']
assert completed_log['extra']['info']['result'] == 'success'
# Check failed log
failed_log = log_calls[4]
assert failed_log['level'] == 'error'
assert 'task_failed' in failed_log['message']
assert failed_log['extra']['error'] == 'Processing failed'
assert failed_log['extra']['error_type'] == 'ValueError'
# Check loop error log
loop_error_log = log_calls[5]
assert loop_error_log['level'] == 'error'
assert 'loop_error' in loop_error_log['message']
# Check stop log
stop_log = log_calls[6]
assert 'stopped' in stop_log['message']
@pytest.mark.asyncio
async def test_concurrent_task_processing(self):
"""Test handling of multiple tasks in sequence."""
class MockTask:
def __init__(self, task_id, should_fail=False):
self.id = task_id
self.processor_type = f'processor_{task_id}'
self.status = 'PENDING'
self.should_fail = should_fail
def get_processor(self):
processor = AsyncMock()
if self.should_fail:
processor.side_effect = Exception(f'Task {self.id} failed')
else:
processor.return_value = {'task_id': self.id, 'result': 'success'}
return processor
class MockMaintenanceTaskRunner:
def __init__(self):
self.processed_tasks = []
self.successful_tasks = []
self.failed_tasks = []
async def _process_pending_tasks(self):
# Simulate finding multiple tasks
tasks = [
MockTask(1, should_fail=False),
MockTask(2, should_fail=True),
MockTask(3, should_fail=False),
]
for task in tasks:
await self._process_task(task)
async def _process_task(self, task):
self.processed_tasks.append(task.id)
try:
processor = task.get_processor()
result = await processor(task)
self.successful_tasks.append((task.id, result))
except Exception as e:
self.failed_tasks.append((task.id, str(e)))
runner = MockMaintenanceTaskRunner()
# Process all pending tasks
await runner._process_pending_tasks()
# Verify all tasks were processed
assert len(runner.processed_tasks) == 3
assert runner.processed_tasks == [1, 2, 3]
# Verify success/failure handling
assert len(runner.successful_tasks) == 2
assert len(runner.failed_tasks) == 1
# Check successful tasks
successful_ids = [task_id for task_id, _ in runner.successful_tasks]
assert 1 in successful_ids
assert 3 in successful_ids
# Check failed task
failed_id, error = runner.failed_tasks[0]
assert failed_id == 2
assert 'Task 2 failed' in error
def test_global_instance_pattern(self):
"""Test the global instance pattern."""
# Mock the global instance pattern
class MockMaintenanceTaskRunner:
def __init__(self):
self.instance_id = id(self)
# Simulate the global instance
global_runner = MockMaintenanceTaskRunner()
# Verify it's a singleton-like pattern
assert global_runner.instance_id == id(global_runner)
# In the actual code, there would be:
# maintenance_task_runner = MaintenanceTaskRunner()
# This ensures a single instance is used throughout the application
@pytest.mark.asyncio
async def test_cancellation_handling(self):
"""Test proper handling of task cancellation."""
class MockMaintenanceTaskRunner:
def __init__(self):
self._running = False
self.cancellation_handled = False
async def _run_loop(self):
try:
while self._running:
await asyncio.sleep(0.01)
except asyncio.CancelledError:
self.cancellation_handled = True
raise # Re-raise to properly handle cancellation
runner = MockMaintenanceTaskRunner()
runner._running = True
# Start the loop and cancel it
task = asyncio.create_task(runner._run_loop())
await asyncio.sleep(0.001) # Let it start
task.cancel()
# Wait for cancellation to be handled
with pytest.raises(asyncio.CancelledError):
await task
assert runner.cancellation_handled is True
# Additional integration test scenarios that would work with full dependencies
class TestMaintenanceTaskRunnerIntegration:
"""
Integration test scenarios for when OpenHands dependencies are available.
These tests would require:
1. OpenHands to be installed and available
2. Database setup with proper migrations
3. Real MaintenanceTask and processor instances
"""
def test_full_runner_workflow_description(self):
"""
Describe the full workflow test that would be implemented with dependencies.
This test would:
1. Create a real MaintenanceTaskRunner instance
2. Set up a test database with MaintenanceTask records
3. Create real processor instances and tasks
4. Start the runner and verify it processes tasks correctly
5. Verify database state changes
6. Verify proper logging and error handling
7. Test the complete start/stop lifecycle
"""
pass
def test_database_integration_description(self):
"""
Describe database integration test that would be implemented.
This test would:
1. Use the session_maker fixture from conftest.py
2. Create MaintenanceTask records with various statuses and start times
3. Run the runner against real database queries
4. Verify that only appropriate tasks are selected and processed
5. Verify database transactions and status updates work correctly
"""
pass
def test_processor_integration_description(self):
"""
Describe processor integration test.
This test would:
1. Create real processor instances (UserVersionUpgradeProcessor, etc.)
2. Store them in MaintenanceTask records
3. Verify the runner can deserialize and execute them correctly
4. Test with both successful and failing processors
5. Verify result storage and error handling
"""
pass
def test_performance_and_scalability_description(self):
"""
Describe performance test scenarios.
This test would:
1. Create a large number of pending tasks
2. Measure processing time and resource usage
3. Verify the runner handles high load gracefully
4. Test memory usage and cleanup
5. Verify proper handling of long-running processors
"""
pass