Files
desk-moloni/tests/QueueProcessorTest.php
Emanuel Almeida 8c4f68576f chore: add spec-kit and standardize signatures
- Added GitHub spec-kit for development workflow
- Standardized file signatures to Descomplicar® format
- Updated development configuration

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 01:27:37 +01:00

628 lines
21 KiB
PHP

/**
* Descomplicar® Crescimento Digital
* https://descomplicar.pt
*/
<?php
require_once __DIR__ . '/TestCase.php';
/**
* QueueProcessorTest
*
* Comprehensive test suite for Queue Processing service
* Tests job queuing, processing, retry logic, conflict resolution, and performance
*
* @package DeskMoloni\Tests
* @author Descomplicar® - PHP Fullstack Engineer
* @version 1.0.0
*/
use DeskMoloni\Libraries\QueueProcessor;
use DeskMoloni\Libraries\EntityMappingService;
use DeskMoloni\Libraries\ErrorHandler;
use DeskMoloni\Libraries\RetryHandler;
class QueueProcessorTest extends \PHPUnit\Framework\TestCase
{
private $queue_processor;
private $redis_mock;
private $model_mock;
private $entity_mapping_mock;
private $error_handler_mock;
private $retry_handler_mock;
private $CI_mock;
protected function setUp(): void
{
// Create mocks for dependencies
$this->redis_mock = $this->createMock(\Redis::class);
$this->model_mock = $this->createMock(Desk_moloni_model::class);
$this->entity_mapping_mock = $this->createMock(EntityMappingService::class);
$this->error_handler_mock = $this->createMock(ErrorHandler::class);
$this->retry_handler_mock = $this->createMock(RetryHandler::class);
// Mock CodeIgniter instance
$this->CI_mock = $this->createMock(stdClass::class);
$this->CI_mock->desk_moloni_model = $this->model_mock;
// Initialize service with DI
$this->queue_processor = new QueueProcessor(
$this->redis_mock,
$this->model_mock,
$this->entity_mapping_mock,
$this->error_handler_mock,
$this->retry_handler_mock
);
}
public function testAddToQueueSuccess()
{
// Test data
$entity_type = EntityMappingService::ENTITY_CUSTOMER;
$entity_id = 123;
$action = 'create';
$direction = 'perfex_to_moloni';
$priority = QueueProcessor::PRIORITY_NORMAL;
$data = ['trigger' => 'client_added'];
// Mock Redis operations
$this->redis_mock
->expects($this->once())
->method('hExists')
->willReturn(false); // No duplicate job
$this->redis_mock
->expects($this->once())
->method('lPush')
->with(
'desk_moloni:queue:main',
$this->callback(function($job_json) use ($entity_type, $entity_id, $action) {
$job = json_decode($job_json, true);
return $job['entity_type'] === $entity_type &&
$job['entity_id'] === $entity_id &&
$job['action'] === $action &&
$job['status'] === QueueProcessor::STATUS_PENDING;
})
);
$this->redis_mock
->expects($this->once())
->method('hSet')
->with('desk_moloni:queue:jobs', $this->isType('string'), $this->isType('string'));
$this->redis_mock
->expects($this->exactly(2))
->method('hIncrBy')
->withConsecutive(
['desk_moloni:queue:stats', 'total_queued', 1],
['desk_moloni:queue:stats', 'queued_customer', 1]
);
// Execute test
$job_id = $this->queue_processor->add_to_queue(
$entity_type,
$entity_id,
$action,
$direction,
$priority,
$data
);
// Assertions
$this->assertNotFalse($job_id);
$this->assertStringContains($entity_type, $job_id);
$this->assertStringContains((string)$entity_id, $job_id);
$this->assertStringContains($action, $job_id);
}
public function testAddToQueueHighPriority()
{
// Test data for high priority job
$entity_type = EntityMappingService::ENTITY_INVOICE;
$entity_id = 456;
$action = 'create';
$priority = QueueProcessor::PRIORITY_HIGH;
// Mock Redis operations for priority queue
$this->redis_mock
->expects($this->once())
->method('hExists')
->willReturn(false);
$this->redis_mock
->expects($this->once())
->method('lPush')
->with('desk_moloni:queue:priority', $this->isType('string'));
$this->redis_mock
->expects($this->once())
->method('hSet');
$this->redis_mock
->expects($this->exactly(2))
->method('hIncrBy');
// Execute test
$job_id = $this->queue_processor->add_to_queue(
$entity_type,
$entity_id,
$action,
'perfex_to_moloni',
$priority
);
// Assertions
$this->assertNotFalse($job_id);
}
public function testAddToQueueWithDelay()
{
// Test data for delayed job
$entity_type = EntityMappingService::ENTITY_PRODUCT;
$entity_id = 789;
$action = 'update';
$delay_seconds = 300; // 5 minutes
// Mock Redis operations for delay queue
$this->redis_mock
->expects($this->once())
->method('hExists')
->willReturn(false);
$this->redis_mock
->expects($this->once())
->method('zAdd')
->with(
'desk_moloni:queue:delay',
$this->callback(function($score) {
return $score > time(); // Should be scheduled for future
}),
$this->isType('string')
);
$this->redis_mock
->expects($this->once())
->method('hSet');
$this->redis_mock
->expects($this->exactly(2))
->method('hIncrBy');
// Execute test
$job_id = $this->queue_processor->add_to_queue(
$entity_type,
$entity_id,
$action,
'perfex_to_moloni',
QueueProcessor::PRIORITY_NORMAL,
[],
$delay_seconds
);
// Assertions
$this->assertNotFalse($job_id);
}
public function testProcessQueueSuccess()
{
// Test data
$job_data = [
'id' => 'customer_123_create_test123',
'entity_type' => EntityMappingService::ENTITY_CUSTOMER,
'entity_id' => 123,
'action' => 'create',
'direction' => 'perfex_to_moloni',
'priority' => QueueProcessor::PRIORITY_NORMAL,
'attempts' => 0,
'max_attempts' => 5,
'status' => QueueProcessor::STATUS_PENDING
];
$job_json = json_encode($job_data);
// Mock queue not paused
$this->redis_mock
->expects($this->once())
->method('get')
->with('desk_moloni:queue:paused')
->willReturn(null);
// Mock delayed jobs processing
$this->redis_mock
->expects($this->once())
->method('zRangeByScore')
->willReturn([]);
// Mock getting next job from priority queue (empty) then main queue
$this->redis_mock
->expects($this->exactly(2))
->method('rPop')
->withConsecutive(
['desk_moloni:queue:priority'],
['desk_moloni:queue:main']
)
->willReturnOnConsecutiveCalls(null, $job_json);
// Mock processing queue operations
$this->redis_mock
->expects($this->once())
->method('hSet')
->with('desk_moloni:queue:processing', $job_data['id'], $job_json);
$this->redis_mock
->expects($this->once())
->method('expire');
// Mock successful job execution
$sync_service_mock = $this->createMock(stdClass::class);
$sync_service_mock
->expects($this->once())
->method('sync_perfex_to_moloni')
->with(123, false, [])
->willReturn([
'success' => true,
'message' => 'Customer synced successfully'
]);
// Mock job completion
$this->redis_mock
->expects($this->once())
->method('hDel')
->with('desk_moloni:queue:processing', $job_data['id']);
// Mock statistics update
$this->redis_mock
->expects($this->exactly(3))
->method('hIncrBy')
->withConsecutive(
['desk_moloni:queue:stats', 'total_processed', 1],
['desk_moloni:queue:stats', 'total_success', 1],
['desk_moloni:queue:stats', 'total_errors', 0]
);
// Use reflection to mock get_sync_service method
$reflection = new ReflectionClass($this->queue_processor);
$method = $reflection->getMethod('get_sync_service');
$method->setAccessible(true);
// Execute test
$result = $this->queue_processor->process_queue(1, 60);
// Assertions
$this->assertEquals(1, $result['processed']);
$this->assertEquals(1, $result['success']);
$this->assertEquals(0, $result['errors']);
$this->assertArrayHasKey('execution_time', $result);
$this->assertArrayHasKey('details', $result);
}
public function testProcessQueueWithRetry()
{
// Test data for failed job that should be retried
$job_data = [
'id' => 'customer_123_create_test123',
'entity_type' => EntityMappingService::ENTITY_CUSTOMER,
'entity_id' => 123,
'action' => 'create',
'direction' => 'perfex_to_moloni',
'priority' => QueueProcessor::PRIORITY_NORMAL,
'attempts' => 1,
'max_attempts' => 5,
'status' => QueueProcessor::STATUS_PENDING
];
$job_json = json_encode($job_data);
// Mock queue processing setup
$this->redis_mock
->expects($this->once())
->method('get')
->willReturn(null); // Not paused
$this->redis_mock
->expects($this->once())
->method('zRangeByScore')
->willReturn([]); // No delayed jobs
$this->redis_mock
->expects($this->exactly(2))
->method('rPop')
->willReturnOnConsecutiveCalls(null, $job_json);
$this->redis_mock
->expects($this->once())
->method('hSet');
$this->redis_mock
->expects($this->once())
->method('expire');
// Mock failed job execution
$sync_service_mock = $this->createMock(stdClass::class);
$sync_service_mock
->expects($this->once())
->method('sync_perfex_to_moloni')
->willThrowException(new Exception('Temporary sync failure'));
// Mock retry handler
$this->retry_handler_mock
->expects($this->once())
->method('calculate_retry_delay')
->with(2) // attempts + 1
->willReturn(120); // 2 minutes
// Mock scheduling retry
$this->redis_mock
->expects($this->once())
->method('hDel')
->with('desk_moloni:queue:processing', $job_data['id']);
$this->redis_mock
->expects($this->once())
->method('zAdd')
->with(
'desk_moloni:queue:delay',
$this->callback(function($score) {
return $score > time();
}),
$this->isType('string')
);
// Mock statistics
$this->redis_mock
->expects($this->exactly(3))
->method('hIncrBy');
// Execute test
$result = $this->queue_processor->process_queue(1, 60);
// Assertions
$this->assertEquals(1, $result['processed']);
$this->assertEquals(0, $result['success']);
$this->assertEquals(1, $result['errors']);
}
public function testProcessQueueDeadLetter()
{
// Test data for job that has exceeded max attempts
$job_data = [
'id' => 'customer_123_create_test123',
'entity_type' => EntityMappingService::ENTITY_CUSTOMER,
'entity_id' => 123,
'action' => 'create',
'direction' => 'perfex_to_moloni',
'priority' => QueueProcessor::PRIORITY_NORMAL,
'attempts' => 5, // Max attempts reached
'max_attempts' => 5,
'status' => QueueProcessor::STATUS_PENDING
];
$job_json = json_encode($job_data);
// Mock queue processing setup
$this->redis_mock
->expects($this->once())
->method('get')
->willReturn(null);
$this->redis_mock
->expects($this->once())
->method('zRangeByScore')
->willReturn([]);
$this->redis_mock
->expects($this->exactly(2))
->method('rPop')
->willReturnOnConsecutiveCalls(null, $job_json);
$this->redis_mock
->expects($this->once())
->method('hSet');
$this->redis_mock
->expects($this->once())
->method('expire');
// Mock failed job execution
$sync_service_mock = $this->createMock(stdClass::class);
$sync_service_mock
->expects($this->once())
->method('sync_perfex_to_moloni')
->willThrowException(new Exception('Permanent failure'));
// Mock moving to dead letter queue
$this->redis_mock
->expects($this->once())
->method('hDel')
->with('desk_moloni:queue:processing', $job_data['id']);
$this->redis_mock
->expects($this->once())
->method('lPush')
->with('desk_moloni:queue:dead_letter', $this->isType('string'));
// Mock error logging
$this->error_handler_mock
->expects($this->once())
->method('log_error')
->with('queue', 'JOB_DEAD_LETTER', $this->stringContains('moved to dead letter'));
// Mock statistics
$this->redis_mock
->expects($this->exactly(3))
->method('hIncrBy');
// Execute test
$result = $this->queue_processor->process_queue(1, 60);
// Assertions
$this->assertEquals(1, $result['processed']);
$this->assertEquals(0, $result['success']);
$this->assertEquals(1, $result['errors']);
}
public function testBidirectionalSyncWithConflict()
{
// Test data for bidirectional sync with conflict
$job_data = [
'id' => 'customer_123_update_test123',
'entity_type' => EntityMappingService::ENTITY_CUSTOMER,
'entity_id' => 123,
'action' => 'update',
'direction' => 'bidirectional',
'priority' => QueueProcessor::PRIORITY_NORMAL,
'attempts' => 0,
'max_attempts' => 5,
'status' => QueueProcessor::STATUS_PENDING
];
$mapping = (object)[
'id' => 1,
'perfex_id' => 123,
'moloni_id' => 456,
'last_sync_perfex' => '2024-01-01 10:00:00',
'last_sync_moloni' => '2024-01-01 09:00:00'
];
// Mock getting mapping
$this->entity_mapping_mock
->expects($this->once())
->method('get_mapping_by_perfex_id')
->with(EntityMappingService::ENTITY_CUSTOMER, 123)
->willReturn($mapping);
// Mock sync service with conflict detection
$sync_service_mock = $this->createMock(stdClass::class);
$sync_service_mock
->expects($this->once())
->method('check_sync_conflicts')
->with($mapping)
->willReturn([
'has_conflict' => true,
'conflict_details' => [
'type' => 'data_conflict',
'field_conflicts' => ['company' => ['perfex_value' => 'A', 'moloni_value' => 'B']]
]
]);
// Mock mapping status update
$this->entity_mapping_mock
->expects($this->once())
->method('update_mapping_status')
->with(1, EntityMappingService::STATUS_CONFLICT, $this->isType('string'));
// Use reflection to test protected method
$reflection = new ReflectionClass($this->queue_processor);
$method = $reflection->getMethod('handle_bidirectional_sync');
$method->setAccessible(true);
// Execute test
$result = $method->invoke($this->queue_processor, $sync_service_mock, $job_data);
// Assertions
$this->assertFalse($result['success']);
$this->assertStringContains('conflict', strtolower($result['message']));
$this->assertArrayHasKey('conflict_details', $result);
}
public function testGetQueueStatistics()
{
// Mock Redis statistics calls
$stats_data = [
'total_queued' => '100',
'total_processed' => '85',
'total_success' => '80',
'total_errors' => '5'
];
$this->redis_mock
->expects($this->once())
->method('hGetAll')
->with('desk_moloni:queue:stats')
->willReturn($stats_data);
$this->redis_mock
->expects($this->exactly(5))
->method('lLen')
->willReturnOnConsecutiveCalls(10, 5, 3, 2, 1); // main, priority, delay, processing, dead_letter
$this->redis_mock
->expects($this->once())
->method('zCard')
->willReturn(3); // delayed jobs
$this->redis_mock
->expects($this->once())
->method('hLen')
->willReturn(2); // processing jobs
// Execute test
$statistics = $this->queue_processor->get_queue_statistics();
// Assertions
$this->assertEquals(10, $statistics['pending_main']);
$this->assertEquals(5, $statistics['pending_priority']);
$this->assertEquals(3, $statistics['delayed']);
$this->assertEquals(2, $statistics['processing']);
$this->assertEquals(1, $statistics['dead_letter']);
$this->assertEquals(100, $statistics['total_queued']);
$this->assertEquals(85, $statistics['total_processed']);
$this->assertEquals(80, $statistics['total_success']);
$this->assertEquals(5, $statistics['total_errors']);
$this->assertEquals(94.12, $statistics['success_rate']); // 80/85 * 100
$this->assertArrayHasKey('memory_usage', $statistics);
$this->assertArrayHasKey('peak_memory', $statistics);
}
public function testHealthCheck()
{
// Mock Redis ping success
$this->redis_mock
->expects($this->once())
->method('ping')
->willReturn(true);
// Mock queue statistics for health check
$this->redis_mock
->expects($this->once())
->method('hGetAll')
->willReturn([]);
$this->redis_mock
->expects($this->exactly(5))
->method('lLen')
->willReturnOnConsecutiveCalls(10, 5, 3, 2, 50); // dead_letter count triggers warning
$this->redis_mock
->expects($this->once())
->method('zCard')
->willReturn(3);
$this->redis_mock
->expects($this->once())
->method('hLen')
->willReturn(2);
// Execute test
$health = $this->queue_processor->health_check();
// Assertions
$this->assertEquals('warning', $health['status']); // Due to high dead letter count
$this->assertEquals('ok', $health['checks']['redis']);
$this->assertStringContains('high count: 50', $health['checks']['dead_letter']);
$this->assertEquals('ok', $health['checks']['processing']);
$this->assertEquals('ok', $health['checks']['memory']);
}
protected function tearDown(): void
{
// Clean up test artifacts
$this->queue_processor = null;
$this->redis_mock = null;
$this->entity_mapping_mock = null;
$this->error_handler_mock = null;
$this->retry_handler_mock = null;
$this->CI_mock = null;
}
}