- Bump DESK_MOLONI version to 3.0.1 across module - Normalize hooks to after_client_* and instantiate PerfexHooks safely - Fix OAuthController view path and API client class name - Add missing admin views for webhook config/logs; adjust view loading - Harden client portal routes and admin routes mapping - Make Dashboard/Logs/Queue tolerant to optional model methods - Align log details query with existing schema; avoid broken joins This makes the module operational in Perfex (admin + client), reduces 404s, and avoids fatal errors due to inconsistent tables/methods.
638 lines
22 KiB
PHP
638 lines
22 KiB
PHP
<?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 $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->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->createMock(stdClass::class);
|
|
|
|
// Initialize service
|
|
$this->queue_processor = new QueueProcessor();
|
|
|
|
// Use reflection to inject mocks
|
|
$reflection = new ReflectionClass($this->queue_processor);
|
|
|
|
$redis_property = $reflection->getProperty('redis');
|
|
$redis_property->setAccessible(true);
|
|
$redis_property->setValue($this->queue_processor, $this->redis_mock);
|
|
|
|
$entity_mapping_property = $reflection->getProperty('entity_mapping');
|
|
$entity_mapping_property->setAccessible(true);
|
|
$entity_mapping_property->setValue($this->queue_processor, $this->entity_mapping_mock);
|
|
|
|
$error_handler_property = $reflection->getProperty('error_handler');
|
|
$error_handler_property->setAccessible(true);
|
|
$error_handler_property->setValue($this->queue_processor, $this->error_handler_mock);
|
|
|
|
$retry_handler_property = $reflection->getProperty('retry_handler');
|
|
$retry_handler_property->setAccessible(true);
|
|
$retry_handler_property->setValue($this->queue_processor, $this->retry_handler_mock);
|
|
|
|
$ci_property = $reflection->getProperty('CI');
|
|
$ci_property->setAccessible(true);
|
|
$ci_property->setValue($this->queue_processor, $this->CI_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;
|
|
}
|
|
} |