fix(perfexcrm module): align version to 3.0.1, unify entrypoint, and harden routes/views
- 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.
This commit is contained in:
467
tests/ClientPortalTest.php
Normal file
467
tests/ClientPortalTest.php
Normal file
@@ -0,0 +1,467 @@
|
||||
<?php
|
||||
|
||||
require_once(__DIR__ . '/../modules/desk_moloni/controllers/ClientPortalController.php');
|
||||
require_once(__DIR__ . '/../modules/desk_moloni/libraries/DocumentAccessControl.php');
|
||||
require_once(__DIR__ . '/../modules/desk_moloni/libraries/ClientNotificationService.php');
|
||||
|
||||
/**
|
||||
* Client Portal Test Suite
|
||||
* Comprehensive tests for client portal backend functionality
|
||||
*
|
||||
* @package Desk-Moloni
|
||||
* @version 3.0.0
|
||||
* @author Descomplicar Business Solutions
|
||||
*/
|
||||
class ClientPortalTest extends PHPUnit\Framework\TestCase
|
||||
{
|
||||
private $clientId;
|
||||
private $testDocumentId;
|
||||
private $testNotificationId;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Set up test environment
|
||||
$this->clientId = 1; // Test client ID
|
||||
$this->testDocumentId = 1; // Test document ID
|
||||
$this->testNotificationId = 1; // Test notification ID
|
||||
|
||||
// Initialize test database if needed
|
||||
$this->_initializeTestDatabase();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Clean up test data
|
||||
$this->_cleanupTestData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Document Access Control
|
||||
*/
|
||||
public function testDocumentAccessControl()
|
||||
{
|
||||
$accessControl = new DocumentAccessControl();
|
||||
|
||||
// Test valid client and document access
|
||||
$hasAccess = $accessControl->canAccessDocument($this->clientId, $this->testDocumentId);
|
||||
$this->assertTrue(is_bool($hasAccess), 'Access control should return boolean');
|
||||
|
||||
// Test invalid parameters
|
||||
$invalidAccess = $accessControl->canAccessDocument(0, $this->testDocumentId);
|
||||
$this->assertFalse($invalidAccess, 'Invalid client ID should return false');
|
||||
|
||||
$invalidDocAccess = $accessControl->canAccessDocument($this->clientId, 0);
|
||||
$this->assertFalse($invalidDocAccess, 'Invalid document ID should return false');
|
||||
|
||||
// Test access validation with details
|
||||
$validation = $accessControl->validateDocumentAccess($this->clientId, $this->testDocumentId, 'view');
|
||||
$this->assertIsArray($validation, 'Validation should return array');
|
||||
$this->assertArrayHasKey('allowed', $validation, 'Validation should have allowed key');
|
||||
$this->assertArrayHasKey('reason', $validation, 'Validation should have reason key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Multiple Document Access
|
||||
*/
|
||||
public function testMultipleDocumentAccess()
|
||||
{
|
||||
$accessControl = new DocumentAccessControl();
|
||||
|
||||
$documentIds = [1, 2, 3];
|
||||
$results = $accessControl->canAccessMultipleDocuments($this->clientId, $documentIds);
|
||||
|
||||
$this->assertIsArray($results, 'Multiple access check should return array');
|
||||
$this->assertCount(3, $results, 'Should return result for each document');
|
||||
|
||||
foreach ($documentIds as $docId) {
|
||||
$this->assertArrayHasKey($docId, $results, "Should have result for document {$docId}");
|
||||
$this->assertIsBool($results[$docId], "Result for document {$docId} should be boolean");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Accessible Documents Retrieval
|
||||
*/
|
||||
public function testGetAccessibleDocuments()
|
||||
{
|
||||
$accessControl = new DocumentAccessControl();
|
||||
|
||||
// Test getting all accessible documents
|
||||
$documents = $accessControl->getAccessibleDocuments($this->clientId);
|
||||
$this->assertIsArray($documents, 'Accessible documents should return array');
|
||||
|
||||
// Test filtering by document type
|
||||
$invoices = $accessControl->getAccessibleDocuments($this->clientId, 'invoice');
|
||||
$this->assertIsArray($invoices, 'Filtered documents should return array');
|
||||
|
||||
// Test with filters
|
||||
$filters = ['status' => 'paid'];
|
||||
$filteredDocs = $accessControl->getAccessibleDocuments($this->clientId, null, $filters);
|
||||
$this->assertIsArray($filteredDocs, 'Documents with filters should return array');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Client Notification Service
|
||||
*/
|
||||
public function testClientNotificationService()
|
||||
{
|
||||
$notificationService = new ClientNotificationService();
|
||||
|
||||
// Test creating notification
|
||||
$notificationId = $notificationService->createNotification(
|
||||
$this->clientId,
|
||||
'document_created',
|
||||
'Test Notification',
|
||||
'This is a test notification',
|
||||
$this->testDocumentId,
|
||||
'http://example.com/test'
|
||||
);
|
||||
|
||||
$this->assertIsInt($notificationId, 'Created notification should return integer ID');
|
||||
$this->assertGreaterThan(0, $notificationId, 'Notification ID should be positive');
|
||||
|
||||
// Test getting client notifications
|
||||
$notifications = $notificationService->getClientNotifications($this->clientId);
|
||||
$this->assertIsArray($notifications, 'Client notifications should return array');
|
||||
|
||||
// Test unread count
|
||||
$unreadCount = $notificationService->getUnreadCount($this->clientId);
|
||||
$this->assertIsInt($unreadCount, 'Unread count should be integer');
|
||||
$this->assertGreaterThanOrEqual(0, $unreadCount, 'Unread count should be non-negative');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Notification Creation Types
|
||||
*/
|
||||
public function testNotificationTypes()
|
||||
{
|
||||
$notificationService = new ClientNotificationService();
|
||||
|
||||
// Test document created notification
|
||||
$docNotification = $notificationService->notifyDocumentCreated(
|
||||
$this->clientId,
|
||||
$this->testDocumentId,
|
||||
'invoice',
|
||||
'INV-2024-001'
|
||||
);
|
||||
$this->assertIsInt($docNotification, 'Document notification should return ID');
|
||||
|
||||
// Test payment received notification
|
||||
$paymentNotification = $notificationService->notifyPaymentReceived(
|
||||
$this->clientId,
|
||||
$this->testDocumentId,
|
||||
100.50,
|
||||
'INV-2024-001'
|
||||
);
|
||||
$this->assertIsInt($paymentNotification, 'Payment notification should return ID');
|
||||
|
||||
// Test overdue notification
|
||||
$overdueNotification = $notificationService->notifyOverdue(
|
||||
$this->clientId,
|
||||
$this->testDocumentId,
|
||||
'INV-2024-001',
|
||||
'2024-01-15'
|
||||
);
|
||||
$this->assertIsInt($overdueNotification, 'Overdue notification should return ID');
|
||||
|
||||
// Test system message
|
||||
$systemNotification = $notificationService->notifySystemMessage(
|
||||
$this->clientId,
|
||||
'System Maintenance',
|
||||
'System will be down for maintenance.'
|
||||
);
|
||||
$this->assertIsInt($systemNotification, 'System notification should return ID');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Notification Management
|
||||
*/
|
||||
public function testNotificationManagement()
|
||||
{
|
||||
$notificationService = new ClientNotificationService();
|
||||
|
||||
// Create test notification
|
||||
$notificationId = $notificationService->createNotification(
|
||||
$this->clientId,
|
||||
'system_message',
|
||||
'Test Management',
|
||||
'Test notification for management testing'
|
||||
);
|
||||
|
||||
// Test getting notification by ID
|
||||
$notification = $notificationService->getNotificationById($notificationId, $this->clientId);
|
||||
$this->assertIsArray($notification, 'Notification by ID should return array');
|
||||
$this->assertEquals($notificationId, $notification['id'], 'Retrieved notification should have correct ID');
|
||||
|
||||
// Test marking as read
|
||||
$markResult = $notificationService->markAsRead($notificationId, $this->clientId);
|
||||
$this->assertTrue($markResult, 'Mark as read should return true');
|
||||
|
||||
// Verify it's marked as read
|
||||
$updatedNotification = $notificationService->getNotificationById($notificationId, $this->clientId);
|
||||
$this->assertTrue($updatedNotification['is_read'], 'Notification should be marked as read');
|
||||
|
||||
// Test mark all as read
|
||||
$markAllResult = $notificationService->markAllAsRead($this->clientId);
|
||||
$this->assertTrue($markAllResult, 'Mark all as read should return true');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Rate Limiting Functionality
|
||||
*/
|
||||
public function testRateLimiting()
|
||||
{
|
||||
// This would test the rate limiting functionality
|
||||
// For now, we'll just verify the structure exists
|
||||
|
||||
$this->assertTrue(method_exists('ClientPortalController', '_checkRateLimit'),
|
||||
'Rate limiting method should exist');
|
||||
|
||||
// Test that rate limiting parameters are reasonable
|
||||
$this->assertTrue(true, 'Rate limiting configuration should be reasonable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API Response Formats
|
||||
*/
|
||||
public function testApiResponseFormats()
|
||||
{
|
||||
// Test success response format
|
||||
$successData = ['test' => 'data'];
|
||||
$this->assertIsArray($successData, 'Success data should be array');
|
||||
|
||||
// Test error response format
|
||||
$errorMessage = 'Test error message';
|
||||
$this->assertIsString($errorMessage, 'Error message should be string');
|
||||
|
||||
// Test pagination response format
|
||||
$pagination = [
|
||||
'current_page' => 1,
|
||||
'per_page' => 20,
|
||||
'total' => 100,
|
||||
'total_pages' => 5,
|
||||
'has_previous' => false,
|
||||
'has_next' => true
|
||||
];
|
||||
|
||||
$this->assertIsArray($pagination, 'Pagination should be array');
|
||||
$this->assertArrayHasKey('current_page', $pagination, 'Pagination should have current_page');
|
||||
$this->assertArrayHasKey('total', $pagination, 'Pagination should have total');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Security Features
|
||||
*/
|
||||
public function testSecurityFeatures()
|
||||
{
|
||||
$accessControl = new DocumentAccessControl();
|
||||
|
||||
// Test security violation logging
|
||||
$accessControl->logSecurityViolation(
|
||||
$this->clientId,
|
||||
$this->testDocumentId,
|
||||
'unauthorized_access',
|
||||
'ownership_violation'
|
||||
);
|
||||
|
||||
$this->assertTrue(true, 'Security violation logging should work without errors');
|
||||
|
||||
// Test input validation
|
||||
$this->assertFalse(
|
||||
$accessControl->canAccessDocument(-1, $this->testDocumentId),
|
||||
'Negative client ID should be rejected'
|
||||
);
|
||||
|
||||
$this->assertFalse(
|
||||
$accessControl->canAccessDocument($this->clientId, -1),
|
||||
'Negative document ID should be rejected'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Performance Considerations
|
||||
*/
|
||||
public function testPerformanceConsiderations()
|
||||
{
|
||||
$accessControl = new DocumentAccessControl();
|
||||
|
||||
// Test that bulk operations are reasonably fast
|
||||
$startTime = microtime(true);
|
||||
|
||||
$documentIds = range(1, 100);
|
||||
$results = $accessControl->canAccessMultipleDocuments($this->clientId, $documentIds);
|
||||
|
||||
$endTime = microtime(true);
|
||||
$duration = $endTime - $startTime;
|
||||
|
||||
$this->assertLessThan(5.0, $duration, 'Bulk access check should complete within 5 seconds');
|
||||
$this->assertCount(100, $results, 'Should return results for all 100 documents');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Error Handling
|
||||
*/
|
||||
public function testErrorHandling()
|
||||
{
|
||||
$notificationService = new ClientNotificationService();
|
||||
|
||||
// Test invalid notification type
|
||||
$result = $notificationService->createNotification(
|
||||
$this->clientId,
|
||||
'invalid_type',
|
||||
'Test',
|
||||
'Test message'
|
||||
);
|
||||
|
||||
$this->assertFalse($result, 'Invalid notification type should return false');
|
||||
|
||||
// Test invalid client ID
|
||||
$result2 = $notificationService->createNotification(
|
||||
-1,
|
||||
'system_message',
|
||||
'Test',
|
||||
'Test message'
|
||||
);
|
||||
|
||||
$this->assertFalse($result2, 'Invalid client ID should return false');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Data Cleanup
|
||||
*/
|
||||
public function testDataCleanup()
|
||||
{
|
||||
$notificationService = new ClientNotificationService();
|
||||
|
||||
// Test old notification cleanup
|
||||
$deletedCount = $notificationService->cleanupOldNotifications(365);
|
||||
$this->assertIsInt($deletedCount, 'Cleanup should return integer count');
|
||||
$this->assertGreaterThanOrEqual(0, $deletedCount, 'Deleted count should be non-negative');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Integration Points
|
||||
*/
|
||||
public function testIntegrationPoints()
|
||||
{
|
||||
// Test that required classes can be instantiated
|
||||
$accessControl = new DocumentAccessControl();
|
||||
$this->assertInstanceOf(DocumentAccessControl::class, $accessControl);
|
||||
|
||||
$notificationService = new ClientNotificationService();
|
||||
$this->assertInstanceOf(ClientNotificationService::class, $notificationService);
|
||||
|
||||
// Test that required methods exist on controller
|
||||
$requiredMethods = [
|
||||
'documents',
|
||||
'document_details',
|
||||
'download_document',
|
||||
'view_document',
|
||||
'dashboard',
|
||||
'notifications',
|
||||
'mark_notification_read',
|
||||
'health_check',
|
||||
'status'
|
||||
];
|
||||
|
||||
foreach ($requiredMethods as $method) {
|
||||
$this->assertTrue(
|
||||
method_exists('ClientPortalController', $method),
|
||||
"Required method {$method} should exist"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private function _initializeTestDatabase()
|
||||
{
|
||||
// Initialize test database if needed
|
||||
// This would set up test tables and data
|
||||
}
|
||||
|
||||
private function _cleanupTestData()
|
||||
{
|
||||
// Clean up any test data created during tests
|
||||
// This ensures tests don't interfere with each other
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API Contract Compliance
|
||||
*/
|
||||
public function testApiContractCompliance()
|
||||
{
|
||||
// Test that the API matches the OpenAPI specification
|
||||
$this->assertTrue(true, 'API should comply with OpenAPI specification');
|
||||
|
||||
// Test required response fields
|
||||
$documentResponse = [
|
||||
'id' => 1,
|
||||
'type' => 'invoice',
|
||||
'number' => 'INV-001',
|
||||
'date' => '2024-01-01',
|
||||
'amount' => 100.00,
|
||||
'currency' => 'EUR',
|
||||
'status' => 'paid',
|
||||
'has_pdf' => true,
|
||||
'pdf_url' => 'http://example.com/pdf',
|
||||
'view_url' => 'http://example.com/view',
|
||||
'download_url' => 'http://example.com/download',
|
||||
'created_at' => '2024-01-01 10:00:00'
|
||||
];
|
||||
|
||||
// Verify all required fields are present
|
||||
$requiredFields = ['id', 'type', 'number', 'date', 'amount', 'currency', 'status', 'has_pdf'];
|
||||
foreach ($requiredFields as $field) {
|
||||
$this->assertArrayHasKey($field, $documentResponse, "Document response should have {$field} field");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Caching Functionality
|
||||
*/
|
||||
public function testCachingFunctionality()
|
||||
{
|
||||
$accessControl = new DocumentAccessControl();
|
||||
|
||||
// Test that cache is being used (timing-based test)
|
||||
$startTime1 = microtime(true);
|
||||
$result1 = $accessControl->canAccessDocument($this->clientId, $this->testDocumentId);
|
||||
$duration1 = microtime(true) - $startTime1;
|
||||
|
||||
$startTime2 = microtime(true);
|
||||
$result2 = $accessControl->canAccessDocument($this->clientId, $this->testDocumentId);
|
||||
$duration2 = microtime(true) - $startTime2;
|
||||
|
||||
$this->assertEquals($result1, $result2, 'Cached result should be the same');
|
||||
|
||||
// Second call should be faster due to caching (though this may not always be reliable in tests)
|
||||
$this->assertLessThanOrEqual($duration1 + 0.001, $duration2 + 0.001, 'Cached call should not be significantly slower');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Logging and Audit Trail
|
||||
*/
|
||||
public function testLoggingAndAuditTrail()
|
||||
{
|
||||
// This would test the logging functionality
|
||||
// For now, we verify the structure exists
|
||||
$this->assertTrue(true, 'Logging should capture all client portal activities');
|
||||
|
||||
// Test that log entries contain required information
|
||||
$logEntry = [
|
||||
'client_id' => $this->clientId,
|
||||
'action' => 'view',
|
||||
'document_id' => $this->testDocumentId,
|
||||
'status' => 'success',
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'Test Agent',
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$requiredLogFields = ['client_id', 'action', 'status', 'timestamp'];
|
||||
foreach ($requiredLogFields as $field) {
|
||||
$this->assertArrayHasKey($field, $logEntry, "Log entry should have {$field} field");
|
||||
}
|
||||
}
|
||||
}
|
||||
490
tests/ClientSyncServiceTest.php
Normal file
490
tests/ClientSyncServiceTest.php
Normal file
@@ -0,0 +1,490 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/TestCase.php';
|
||||
|
||||
/**
|
||||
* ClientSyncServiceTest
|
||||
*
|
||||
* Comprehensive test suite for Client/Customer synchronization service
|
||||
* Tests bidirectional sync, conflict resolution, data validation, and error handling
|
||||
*
|
||||
* @package DeskMoloni\Tests
|
||||
* @author Descomplicar® - PHP Fullstack Engineer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use DeskMoloni\Libraries\ClientSyncService;
|
||||
use DeskMoloni\Libraries\EntityMappingService;
|
||||
use DeskMoloni\Libraries\ErrorHandler;
|
||||
use DeskMoloni\Libraries\MoloniApiClient;
|
||||
|
||||
class ClientSyncServiceTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
private $client_sync_service;
|
||||
private $entity_mapping_mock;
|
||||
private $api_client_mock;
|
||||
private $error_handler_mock;
|
||||
private $CI_mock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Create mocks for dependencies
|
||||
$this->entity_mapping_mock = $this->createMock(EntityMappingService::class);
|
||||
$this->api_client_mock = $this->createMock(MoloniApiClient::class);
|
||||
$this->error_handler_mock = $this->createMock(ErrorHandler::class);
|
||||
|
||||
// Mock CodeIgniter instance
|
||||
$this->CI_mock = $this->createMock(stdClass::class);
|
||||
$this->CI_mock->clients_model = $this->createMock(stdClass::class);
|
||||
$this->CI_mock->desk_moloni_model = $this->createMock(stdClass::class);
|
||||
|
||||
// Initialize service with mocked dependencies
|
||||
$this->client_sync_service = new ClientSyncService();
|
||||
|
||||
// Use reflection to inject mocks
|
||||
$reflection = new ReflectionClass($this->client_sync_service);
|
||||
|
||||
$entity_mapping_property = $reflection->getProperty('entity_mapping');
|
||||
$entity_mapping_property->setAccessible(true);
|
||||
$entity_mapping_property->setValue($this->client_sync_service, $this->entity_mapping_mock);
|
||||
|
||||
$api_client_property = $reflection->getProperty('api_client');
|
||||
$api_client_property->setAccessible(true);
|
||||
$api_client_property->setValue($this->client_sync_service, $this->api_client_mock);
|
||||
|
||||
$error_handler_property = $reflection->getProperty('error_handler');
|
||||
$error_handler_property->setAccessible(true);
|
||||
$error_handler_property->setValue($this->client_sync_service, $this->error_handler_mock);
|
||||
|
||||
$ci_property = $reflection->getProperty('CI');
|
||||
$ci_property->setAccessible(true);
|
||||
$ci_property->setValue($this->client_sync_service, $this->CI_mock);
|
||||
}
|
||||
|
||||
public function testSyncPerfexToMoloniSuccess()
|
||||
{
|
||||
// Test data
|
||||
$perfex_client_id = 123;
|
||||
$perfex_client = [
|
||||
'userid' => 123,
|
||||
'company' => 'Test Company Ltd',
|
||||
'vat' => 'PT123456789',
|
||||
'email' => 'test@example.com',
|
||||
'phonenumber' => '+351912345678',
|
||||
'billing_street' => 'Test Street 123',
|
||||
'billing_city' => 'Porto',
|
||||
'billing_zip' => '4000-001',
|
||||
'billing_country' => 'PT'
|
||||
];
|
||||
|
||||
$moloni_customer_data = [
|
||||
'name' => 'Test Company Ltd',
|
||||
'vat' => 'PT123456789',
|
||||
'email' => 'test@example.com',
|
||||
'phone' => '+351912345678',
|
||||
'address' => 'Test Street 123',
|
||||
'city' => 'Porto',
|
||||
'zip_code' => '4000-001',
|
||||
'country_id' => 1
|
||||
];
|
||||
|
||||
// Mock no existing mapping
|
||||
$this->entity_mapping_mock
|
||||
->expects($this->once())
|
||||
->method('get_mapping_by_perfex_id')
|
||||
->with(EntityMappingService::ENTITY_CUSTOMER, $perfex_client_id)
|
||||
->willReturn(null);
|
||||
|
||||
// Mock successful Perfex client retrieval
|
||||
$this->CI_mock->clients_model
|
||||
->expects($this->once())
|
||||
->method('get')
|
||||
->with($perfex_client_id)
|
||||
->willReturn((object)$perfex_client);
|
||||
|
||||
// Mock successful Moloni API call
|
||||
$this->api_client_mock
|
||||
->expects($this->once())
|
||||
->method('create_customer')
|
||||
->with($this->callback(function($data) use ($moloni_customer_data) {
|
||||
return $data['name'] === $moloni_customer_data['name'] &&
|
||||
$data['vat'] === $moloni_customer_data['vat'] &&
|
||||
$data['email'] === $moloni_customer_data['email'];
|
||||
}))
|
||||
->willReturn([
|
||||
'success' => true,
|
||||
'data' => ['customer_id' => 456]
|
||||
]);
|
||||
|
||||
// Mock mapping creation
|
||||
$this->entity_mapping_mock
|
||||
->expects($this->once())
|
||||
->method('create_mapping')
|
||||
->with(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
$perfex_client_id,
|
||||
456,
|
||||
EntityMappingService::DIRECTION_PERFEX_TO_MOLONI
|
||||
)
|
||||
->willReturn(1);
|
||||
|
||||
// Mock activity logging
|
||||
$this->CI_mock->desk_moloni_model
|
||||
->expects($this->once())
|
||||
->method('log_sync_activity')
|
||||
->with($this->callback(function($data) {
|
||||
return $data['entity_type'] === 'customer' &&
|
||||
$data['action'] === 'create' &&
|
||||
$data['direction'] === 'perfex_to_moloni' &&
|
||||
$data['status'] === 'success';
|
||||
}));
|
||||
|
||||
// Execute test
|
||||
$result = $this->client_sync_service->sync_perfex_to_moloni($perfex_client_id);
|
||||
|
||||
// Assertions
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('Customer created successfully in Moloni', $result['message']);
|
||||
$this->assertEquals(1, $result['mapping_id']);
|
||||
$this->assertEquals(456, $result['moloni_customer_id']);
|
||||
$this->assertEquals('create', $result['action']);
|
||||
$this->assertArrayHasKey('execution_time', $result);
|
||||
}
|
||||
|
||||
public function testSyncMoloniToPerfexSuccess()
|
||||
{
|
||||
// Test data
|
||||
$moloni_customer_id = 456;
|
||||
$moloni_customer = [
|
||||
'customer_id' => 456,
|
||||
'name' => 'Test Company Ltd',
|
||||
'vat' => 'PT123456789',
|
||||
'email' => 'test@example.com',
|
||||
'phone' => '+351912345678',
|
||||
'address' => 'Test Street 123',
|
||||
'city' => 'Porto',
|
||||
'zip_code' => '4000-001',
|
||||
'country_id' => 1
|
||||
];
|
||||
|
||||
$perfex_client_data = [
|
||||
'company' => 'Test Company Ltd',
|
||||
'vat' => 'PT123456789',
|
||||
'email' => 'test@example.com',
|
||||
'phonenumber' => '+351912345678',
|
||||
'billing_street' => 'Test Street 123',
|
||||
'billing_city' => 'Porto',
|
||||
'billing_zip' => '4000-001',
|
||||
'billing_country' => 'PT'
|
||||
];
|
||||
|
||||
// Mock no existing mapping
|
||||
$this->entity_mapping_mock
|
||||
->expects($this->once())
|
||||
->method('get_mapping_by_moloni_id')
|
||||
->with(EntityMappingService::ENTITY_CUSTOMER, $moloni_customer_id)
|
||||
->willReturn(null);
|
||||
|
||||
// Mock successful Moloni customer retrieval
|
||||
$this->api_client_mock
|
||||
->expects($this->once())
|
||||
->method('get_customer')
|
||||
->with($moloni_customer_id)
|
||||
->willReturn([
|
||||
'success' => true,
|
||||
'data' => $moloni_customer
|
||||
]);
|
||||
|
||||
// Mock successful Perfex client creation
|
||||
$this->CI_mock->clients_model
|
||||
->expects($this->once())
|
||||
->method('add')
|
||||
->with($this->callback(function($data) use ($perfex_client_data) {
|
||||
return $data['company'] === $perfex_client_data['company'] &&
|
||||
$data['vat'] === $perfex_client_data['vat'] &&
|
||||
$data['email'] === $perfex_client_data['email'];
|
||||
}))
|
||||
->willReturn(123);
|
||||
|
||||
// Mock mapping creation
|
||||
$this->entity_mapping_mock
|
||||
->expects($this->once())
|
||||
->method('create_mapping')
|
||||
->with(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
123,
|
||||
$moloni_customer_id,
|
||||
EntityMappingService::DIRECTION_MOLONI_TO_PERFEX
|
||||
)
|
||||
->willReturn(1);
|
||||
|
||||
// Execute test
|
||||
$result = $this->client_sync_service->sync_moloni_to_perfex($moloni_customer_id);
|
||||
|
||||
// Assertions
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('Customer created successfully in Perfex', $result['message']);
|
||||
$this->assertEquals(1, $result['mapping_id']);
|
||||
$this->assertEquals(123, $result['perfex_client_id']);
|
||||
$this->assertEquals('create', $result['action']);
|
||||
}
|
||||
|
||||
public function testSyncPerfexToMoloniWithConflict()
|
||||
{
|
||||
// Test data
|
||||
$perfex_client_id = 123;
|
||||
$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'
|
||||
];
|
||||
|
||||
$perfex_client = [
|
||||
'userid' => 123,
|
||||
'company' => 'Test Company Ltd - Updated',
|
||||
'vat' => 'PT123456789',
|
||||
'email' => 'test@example.com'
|
||||
];
|
||||
|
||||
$moloni_customer = [
|
||||
'customer_id' => 456,
|
||||
'name' => 'Test Company Ltd - Different Update',
|
||||
'vat' => 'PT123456789',
|
||||
'email' => 'test@example.com'
|
||||
];
|
||||
|
||||
// Mock existing mapping
|
||||
$this->entity_mapping_mock
|
||||
->expects($this->once())
|
||||
->method('get_mapping_by_perfex_id')
|
||||
->with(EntityMappingService::ENTITY_CUSTOMER, $perfex_client_id)
|
||||
->willReturn($mapping);
|
||||
|
||||
// Mock Perfex client retrieval
|
||||
$this->CI_mock->clients_model
|
||||
->expects($this->once())
|
||||
->method('get')
|
||||
->with($perfex_client_id)
|
||||
->willReturn((object)$perfex_client);
|
||||
|
||||
// Mock Moloni customer retrieval for conflict check
|
||||
$this->api_client_mock
|
||||
->expects($this->once())
|
||||
->method('get_customer')
|
||||
->with(456)
|
||||
->willReturn([
|
||||
'success' => true,
|
||||
'data' => $moloni_customer
|
||||
]);
|
||||
|
||||
// Mock mapping status update to conflict
|
||||
$this->entity_mapping_mock
|
||||
->expects($this->once())
|
||||
->method('update_mapping_status')
|
||||
->with(
|
||||
1,
|
||||
EntityMappingService::STATUS_CONFLICT,
|
||||
$this->isType('string')
|
||||
);
|
||||
|
||||
// Execute test
|
||||
$result = $this->client_sync_service->sync_perfex_to_moloni($perfex_client_id);
|
||||
|
||||
// Assertions
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertStringContains('conflict', strtolower($result['message']));
|
||||
$this->assertArrayHasKey('conflict_details', $result);
|
||||
$this->assertTrue($result['requires_manual_resolution']);
|
||||
}
|
||||
|
||||
public function testFindMoloniCustomerMatches()
|
||||
{
|
||||
// Test data
|
||||
$perfex_client = [
|
||||
'company' => 'Test Company Ltd',
|
||||
'vat' => 'PT123456789',
|
||||
'email' => 'test@example.com',
|
||||
'phonenumber' => '+351912345678'
|
||||
];
|
||||
|
||||
$moloni_matches = [
|
||||
[
|
||||
'customer_id' => 456,
|
||||
'name' => 'Test Company Ltd',
|
||||
'vat' => 'PT123456789',
|
||||
'email' => 'test@example.com'
|
||||
]
|
||||
];
|
||||
|
||||
// Mock VAT search returning exact match
|
||||
$this->api_client_mock
|
||||
->expects($this->once())
|
||||
->method('search_customers')
|
||||
->with(['vat' => 'PT123456789'])
|
||||
->willReturn([
|
||||
'success' => true,
|
||||
'data' => $moloni_matches
|
||||
]);
|
||||
|
||||
// Execute test
|
||||
$matches = $this->client_sync_service->find_moloni_customer_matches($perfex_client);
|
||||
|
||||
// Assertions
|
||||
$this->assertCount(1, $matches);
|
||||
$this->assertEquals(100, $matches[0]['match_score']); // Exact match
|
||||
$this->assertEquals('vat', $matches[0]['match_type']);
|
||||
$this->assertEquals(['vat' => 'PT123456789'], $matches[0]['match_criteria']);
|
||||
}
|
||||
|
||||
public function testSyncPerfexToMoloniWithMissingClient()
|
||||
{
|
||||
// Test data
|
||||
$perfex_client_id = 999; // Non-existent client
|
||||
|
||||
// Mock no existing mapping
|
||||
$this->entity_mapping_mock
|
||||
->expects($this->once())
|
||||
->method('get_mapping_by_perfex_id')
|
||||
->with(EntityMappingService::ENTITY_CUSTOMER, $perfex_client_id)
|
||||
->willReturn(null);
|
||||
|
||||
// Mock client not found
|
||||
$this->CI_mock->clients_model
|
||||
->expects($this->once())
|
||||
->method('get')
|
||||
->with($perfex_client_id)
|
||||
->willReturn(null);
|
||||
|
||||
// Mock error logging
|
||||
$this->error_handler_mock
|
||||
->expects($this->once())
|
||||
->method('log_error')
|
||||
->with('sync', 'CLIENT_SYNC_FAILED', $this->stringContains('not found'));
|
||||
|
||||
// Execute test
|
||||
$result = $this->client_sync_service->sync_perfex_to_moloni($perfex_client_id);
|
||||
|
||||
// Assertions
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertStringContains('not found', $result['message']);
|
||||
$this->assertArrayHasKey('execution_time', $result);
|
||||
}
|
||||
|
||||
public function testSyncPerfexToMoloniWithApiError()
|
||||
{
|
||||
// Test data
|
||||
$perfex_client_id = 123;
|
||||
$perfex_client = [
|
||||
'userid' => 123,
|
||||
'company' => 'Test Company Ltd',
|
||||
'vat' => 'PT123456789',
|
||||
'email' => 'test@example.com'
|
||||
];
|
||||
|
||||
// Mock no existing mapping
|
||||
$this->entity_mapping_mock
|
||||
->expects($this->once())
|
||||
->method('get_mapping_by_perfex_id')
|
||||
->willReturn(null);
|
||||
|
||||
// Mock successful Perfex client retrieval
|
||||
$this->CI_mock->clients_model
|
||||
->expects($this->once())
|
||||
->method('get')
|
||||
->willReturn((object)$perfex_client);
|
||||
|
||||
// Mock Moloni API error
|
||||
$this->api_client_mock
|
||||
->expects($this->once())
|
||||
->method('create_customer')
|
||||
->willReturn([
|
||||
'success' => false,
|
||||
'message' => 'Moloni API connection failed'
|
||||
]);
|
||||
|
||||
// Mock error logging
|
||||
$this->error_handler_mock
|
||||
->expects($this->once())
|
||||
->method('log_error')
|
||||
->with('sync', 'CLIENT_SYNC_FAILED', $this->stringContains('Moloni API'));
|
||||
|
||||
// Execute test
|
||||
$result = $this->client_sync_service->sync_perfex_to_moloni($perfex_client_id);
|
||||
|
||||
// Assertions
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertStringContains('Moloni API', $result['message']);
|
||||
}
|
||||
|
||||
public function testClientUpdateWithSignificantChanges()
|
||||
{
|
||||
// Test data reflecting significant field changes
|
||||
$perfex_client_id = 123;
|
||||
$original_client = [
|
||||
'userid' => 123,
|
||||
'company' => 'Test Company Ltd',
|
||||
'vat' => 'PT123456789',
|
||||
'email' => 'old@example.com'
|
||||
];
|
||||
|
||||
$updated_client = [
|
||||
'userid' => 123,
|
||||
'company' => 'Test Company Ltd',
|
||||
'vat' => 'PT123456789',
|
||||
'email' => 'new@example.com' // Significant change
|
||||
];
|
||||
|
||||
$mapping = (object)[
|
||||
'id' => 1,
|
||||
'perfex_id' => 123,
|
||||
'moloni_id' => 456,
|
||||
'sync_status' => EntityMappingService::STATUS_SYNCED
|
||||
];
|
||||
|
||||
// Mock existing mapping
|
||||
$this->entity_mapping_mock
|
||||
->expects($this->once())
|
||||
->method('get_mapping_by_perfex_id')
|
||||
->willReturn($mapping);
|
||||
|
||||
// Mock updated client data
|
||||
$this->CI_mock->clients_model
|
||||
->expects($this->once())
|
||||
->method('get')
|
||||
->willReturn((object)$updated_client);
|
||||
|
||||
// Mock successful update
|
||||
$this->api_client_mock
|
||||
->expects($this->once())
|
||||
->method('update_customer')
|
||||
->willReturn([
|
||||
'success' => true,
|
||||
'data' => ['customer_id' => 456]
|
||||
]);
|
||||
|
||||
// Mock mapping update
|
||||
$this->entity_mapping_mock
|
||||
->expects($this->once())
|
||||
->method('update_mapping');
|
||||
|
||||
// Execute test
|
||||
$result = $this->client_sync_service->sync_perfex_to_moloni($perfex_client_id, true);
|
||||
|
||||
// Assertions
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('update', $result['action']);
|
||||
$this->assertArrayHasKey('data_changes', $result);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Clean up any test artifacts
|
||||
$this->client_sync_service = null;
|
||||
$this->entity_mapping_mock = null;
|
||||
$this->api_client_mock = null;
|
||||
$this->error_handler_mock = null;
|
||||
$this->CI_mock = null;
|
||||
}
|
||||
}
|
||||
478
tests/IntegrationTest.php
Normal file
478
tests/IntegrationTest.php
Normal file
@@ -0,0 +1,478 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/TestCase.php';
|
||||
|
||||
/**
|
||||
* IntegrationTest
|
||||
*
|
||||
* End-to-end integration tests for the complete Desk-Moloni synchronization system
|
||||
* Tests full workflow from hooks to queue processing to API calls
|
||||
*
|
||||
* @package DeskMoloni\Tests
|
||||
* @author Descomplicar® - PHP Fullstack Engineer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use DeskMoloni\Libraries\ClientSyncService;
|
||||
use DeskMoloni\Libraries\ProductSyncService;
|
||||
use DeskMoloni\Libraries\InvoiceSyncService;
|
||||
use DeskMoloni\Libraries\EstimateSyncService;
|
||||
use DeskMoloni\Libraries\QueueProcessor;
|
||||
use DeskMoloni\Libraries\PerfexHooks;
|
||||
use DeskMoloni\Libraries\EntityMappingService;
|
||||
|
||||
class IntegrationTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
private $client_sync;
|
||||
private $product_sync;
|
||||
private $invoice_sync;
|
||||
private $estimate_sync;
|
||||
private $queue_processor;
|
||||
private $perfex_hooks;
|
||||
private $entity_mapping;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Initialize all services
|
||||
$this->client_sync = new ClientSyncService();
|
||||
$this->product_sync = new ProductSyncService();
|
||||
$this->invoice_sync = new InvoiceSyncService();
|
||||
$this->estimate_sync = new EstimateSyncService();
|
||||
$this->queue_processor = new QueueProcessor();
|
||||
$this->perfex_hooks = new PerfexHooks();
|
||||
$this->entity_mapping = new EntityMappingService();
|
||||
|
||||
// Clear any existing test data
|
||||
$this->cleanupTestData();
|
||||
}
|
||||
|
||||
public function testCompleteCustomerSyncWorkflow()
|
||||
{
|
||||
// Test complete customer synchronization workflow
|
||||
|
||||
// Step 1: Create customer in Perfex (simulates user action)
|
||||
$perfex_client_data = [
|
||||
'company' => 'Integration Test Company Ltd',
|
||||
'vat' => 'PT999888777',
|
||||
'email' => 'integration@test.com',
|
||||
'phonenumber' => '+351999888777',
|
||||
'billing_street' => 'Test Integration Street 123',
|
||||
'billing_city' => 'Porto',
|
||||
'billing_zip' => '4000-999',
|
||||
'billing_country' => 'PT',
|
||||
'active' => 1
|
||||
];
|
||||
|
||||
// Mock Perfex client creation
|
||||
$perfex_client_id = 9999; // Simulated ID
|
||||
|
||||
// Step 2: Hook triggers queue job
|
||||
$job_id = $this->queue_processor->add_to_queue(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
$perfex_client_id,
|
||||
'create',
|
||||
'perfex_to_moloni',
|
||||
QueueProcessor::PRIORITY_NORMAL,
|
||||
['trigger' => 'integration_test']
|
||||
);
|
||||
|
||||
$this->assertNotFalse($job_id, 'Job should be queued successfully');
|
||||
|
||||
// Step 3: Process queue (simulates background processing)
|
||||
$result = $this->queue_processor->process_queue(1, 60);
|
||||
|
||||
$this->assertEquals(1, $result['processed'], 'One job should be processed');
|
||||
$this->assertEquals(1, $result['success'], 'Job should complete successfully');
|
||||
$this->assertEquals(0, $result['errors'], 'No errors should occur');
|
||||
|
||||
// Step 4: Verify mapping was created
|
||||
$mapping = $this->entity_mapping->get_mapping_by_perfex_id(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
$perfex_client_id
|
||||
);
|
||||
|
||||
$this->assertNotNull($mapping, 'Entity mapping should be created');
|
||||
$this->assertEquals(EntityMappingService::STATUS_SYNCED, $mapping->sync_status);
|
||||
$this->assertEquals(EntityMappingService::DIRECTION_PERFEX_TO_MOLONI, $mapping->sync_direction);
|
||||
|
||||
return [
|
||||
'perfex_client_id' => $perfex_client_id,
|
||||
'moloni_customer_id' => $mapping->moloni_id,
|
||||
'mapping_id' => $mapping->id
|
||||
];
|
||||
}
|
||||
|
||||
public function testCompleteInvoiceWorkflowWithDependencies()
|
||||
{
|
||||
// Test complete invoice sync with customer dependency
|
||||
|
||||
// Step 1: Ensure customer exists and is synced
|
||||
$customer_data = $this->testCompleteCustomerSyncWorkflow();
|
||||
|
||||
// Step 2: Create invoice in Perfex
|
||||
$perfex_invoice_data = [
|
||||
'clientid' => $customer_data['perfex_client_id'],
|
||||
'number' => 'INV-TEST-2024-001',
|
||||
'date' => date('Y-m-d'),
|
||||
'duedate' => date('Y-m-d', strtotime('+30 days')),
|
||||
'subtotal' => 100.00,
|
||||
'total_tax' => 23.00,
|
||||
'total' => 123.00,
|
||||
'status' => 1, // Draft
|
||||
'currency' => 1
|
||||
];
|
||||
|
||||
$perfex_invoice_id = 8888; // Simulated ID
|
||||
|
||||
// Step 3: Queue invoice sync
|
||||
$job_id = $this->queue_processor->add_to_queue(
|
||||
EntityMappingService::ENTITY_INVOICE,
|
||||
$perfex_invoice_id,
|
||||
'create',
|
||||
'perfex_to_moloni',
|
||||
QueueProcessor::PRIORITY_HIGH,
|
||||
['trigger' => 'invoice_added']
|
||||
);
|
||||
|
||||
$this->assertNotFalse($job_id, 'Invoice job should be queued');
|
||||
|
||||
// Step 4: Process queue
|
||||
$result = $this->queue_processor->process_queue(1, 60);
|
||||
|
||||
$this->assertEquals(1, $result['processed'], 'Invoice job should be processed');
|
||||
|
||||
// Step 5: Verify invoice mapping
|
||||
$invoice_mapping = $this->entity_mapping->get_mapping_by_perfex_id(
|
||||
EntityMappingService::ENTITY_INVOICE,
|
||||
$perfex_invoice_id
|
||||
);
|
||||
|
||||
$this->assertNotNull($invoice_mapping, 'Invoice mapping should be created');
|
||||
$this->assertEquals(EntityMappingService::STATUS_SYNCED, $invoice_mapping->sync_status);
|
||||
|
||||
return [
|
||||
'perfex_invoice_id' => $perfex_invoice_id,
|
||||
'moloni_invoice_id' => $invoice_mapping->moloni_id,
|
||||
'customer_data' => $customer_data
|
||||
];
|
||||
}
|
||||
|
||||
public function testBidirectionalSyncConflictResolution()
|
||||
{
|
||||
// Test conflict detection and resolution in bidirectional sync
|
||||
|
||||
// Step 1: Create initial sync
|
||||
$customer_data = $this->testCompleteCustomerSyncWorkflow();
|
||||
|
||||
// Step 2: Simulate concurrent modifications
|
||||
// Update in Perfex
|
||||
$perfex_update_data = [
|
||||
'company' => 'Updated Company Name - Perfex',
|
||||
'email' => 'updated.perfex@test.com'
|
||||
];
|
||||
|
||||
// Update in Moloni (simulated)
|
||||
$moloni_update_data = [
|
||||
'name' => 'Updated Company Name - Moloni',
|
||||
'email' => 'updated.moloni@test.com'
|
||||
];
|
||||
|
||||
// Step 3: Trigger bidirectional sync
|
||||
$job_id = $this->queue_processor->add_to_queue(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
$customer_data['perfex_client_id'],
|
||||
'update',
|
||||
'bidirectional',
|
||||
QueueProcessor::PRIORITY_NORMAL,
|
||||
['trigger' => 'conflict_test']
|
||||
);
|
||||
|
||||
// Step 4: Process should detect conflict
|
||||
$result = $this->queue_processor->process_queue(1, 60);
|
||||
|
||||
// Step 5: Verify conflict handling
|
||||
$mapping = $this->entity_mapping->get_mapping_by_perfex_id(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
$customer_data['perfex_client_id']
|
||||
);
|
||||
|
||||
// Depending on conflict resolution strategy, mapping should be marked as conflict
|
||||
// or resolved according to the configured strategy
|
||||
$this->assertNotNull($mapping);
|
||||
|
||||
// If manual resolution is configured, status should be CONFLICT
|
||||
if (get_option('desk_moloni_conflict_strategy', 'manual') === 'manual') {
|
||||
$this->assertEquals(EntityMappingService::STATUS_CONFLICT, $mapping->sync_status);
|
||||
}
|
||||
}
|
||||
|
||||
public function testQueuePriorityAndOrdering()
|
||||
{
|
||||
// Test queue priority system and job ordering
|
||||
|
||||
$jobs = [];
|
||||
|
||||
// Add jobs with different priorities
|
||||
$jobs['low'] = $this->queue_processor->add_to_queue(
|
||||
EntityMappingService::ENTITY_PRODUCT,
|
||||
1001,
|
||||
'create',
|
||||
'perfex_to_moloni',
|
||||
QueueProcessor::PRIORITY_LOW,
|
||||
['test' => 'low_priority']
|
||||
);
|
||||
|
||||
$jobs['critical'] = $this->queue_processor->add_to_queue(
|
||||
EntityMappingService::ENTITY_INVOICE,
|
||||
1002,
|
||||
'update',
|
||||
'perfex_to_moloni',
|
||||
QueueProcessor::PRIORITY_CRITICAL,
|
||||
['test' => 'critical_priority']
|
||||
);
|
||||
|
||||
$jobs['normal'] = $this->queue_processor->add_to_queue(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
1003,
|
||||
'create',
|
||||
'perfex_to_moloni',
|
||||
QueueProcessor::PRIORITY_NORMAL,
|
||||
['test' => 'normal_priority']
|
||||
);
|
||||
|
||||
$jobs['high'] = $this->queue_processor->add_to_queue(
|
||||
EntityMappingService::ENTITY_INVOICE,
|
||||
1004,
|
||||
'create',
|
||||
'perfex_to_moloni',
|
||||
QueueProcessor::PRIORITY_HIGH,
|
||||
['test' => 'high_priority']
|
||||
);
|
||||
|
||||
// All jobs should be added successfully
|
||||
foreach ($jobs as $priority => $job_id) {
|
||||
$this->assertNotFalse($job_id, "Job with {$priority} priority should be queued");
|
||||
}
|
||||
|
||||
// Process all jobs
|
||||
$result = $this->queue_processor->process_queue(4, 120);
|
||||
|
||||
$this->assertEquals(4, $result['processed'], 'All 4 jobs should be processed');
|
||||
|
||||
// Verify that high priority jobs were processed first
|
||||
// This would require additional tracking in the actual implementation
|
||||
$this->assertGreaterThan(0, $result['success']);
|
||||
}
|
||||
|
||||
public function testRetryMechanismWithExponentialBackoff()
|
||||
{
|
||||
// Test retry mechanism for failed jobs
|
||||
|
||||
// Create a job that will fail initially
|
||||
$job_id = $this->queue_processor->add_to_queue(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
9998, // Non-existent customer to trigger failure
|
||||
'create',
|
||||
'perfex_to_moloni',
|
||||
QueueProcessor::PRIORITY_NORMAL,
|
||||
['test' => 'retry_mechanism']
|
||||
);
|
||||
|
||||
$this->assertNotFalse($job_id);
|
||||
|
||||
// First processing attempt should fail and schedule retry
|
||||
$result = $this->queue_processor->process_queue(1, 30);
|
||||
|
||||
$this->assertEquals(1, $result['processed']);
|
||||
$this->assertEquals(0, $result['success']);
|
||||
$this->assertEquals(1, $result['errors']);
|
||||
|
||||
// Check queue statistics to verify retry was scheduled
|
||||
$stats = $this->queue_processor->get_queue_statistics();
|
||||
$this->assertGreaterThan(0, $stats['delayed']); // Job should be in delay queue
|
||||
|
||||
// Simulate time passing and process delayed jobs
|
||||
// In real scenario, this would be handled by cron job
|
||||
$delayed_result = $this->queue_processor->process_queue(1, 30);
|
||||
|
||||
// Job should be attempted again
|
||||
$this->assertGreaterThanOrEqual(0, $delayed_result['processed']);
|
||||
}
|
||||
|
||||
public function testBulkSynchronizationPerformance()
|
||||
{
|
||||
// Test bulk synchronization performance
|
||||
|
||||
$start_time = microtime(true);
|
||||
$job_count = 10;
|
||||
$jobs = [];
|
||||
|
||||
// Queue multiple jobs
|
||||
for ($i = 1; $i <= $job_count; $i++) {
|
||||
$jobs[] = $this->queue_processor->add_to_queue(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
7000 + $i,
|
||||
'create',
|
||||
'perfex_to_moloni',
|
||||
QueueProcessor::PRIORITY_NORMAL,
|
||||
['test' => 'bulk_sync', 'batch_id' => $i]
|
||||
);
|
||||
}
|
||||
|
||||
$queue_time = microtime(true) - $start_time;
|
||||
|
||||
// All jobs should be queued successfully
|
||||
$this->assertCount($job_count, array_filter($jobs));
|
||||
|
||||
// Process all jobs in batch
|
||||
$process_start = microtime(true);
|
||||
$result = $this->queue_processor->process_queue($job_count, 300);
|
||||
$process_time = microtime(true) - $process_start;
|
||||
|
||||
// Performance assertions
|
||||
$this->assertEquals($job_count, $result['processed']);
|
||||
$this->assertLessThan(5.0, $queue_time, 'Queuing should be fast');
|
||||
$this->assertLessThan(30.0, $process_time, 'Processing should complete within reasonable time');
|
||||
|
||||
// Memory usage should be reasonable
|
||||
$stats = $this->queue_processor->get_queue_statistics();
|
||||
$memory_mb = $stats['memory_usage'] / (1024 * 1024);
|
||||
$this->assertLessThan(100, $memory_mb, 'Memory usage should be under 100MB');
|
||||
}
|
||||
|
||||
public function testErrorHandlingAndLogging()
|
||||
{
|
||||
// Test comprehensive error handling and logging
|
||||
|
||||
// Create job with invalid data to trigger errors
|
||||
$job_id = $this->queue_processor->add_to_queue(
|
||||
'invalid_entity_type', // Invalid entity type
|
||||
1234,
|
||||
'create',
|
||||
'perfex_to_moloni',
|
||||
QueueProcessor::PRIORITY_NORMAL,
|
||||
['test' => 'error_handling']
|
||||
);
|
||||
|
||||
// Job should not be created due to validation
|
||||
$this->assertFalse($job_id, 'Invalid job should not be queued');
|
||||
|
||||
// Create valid job but with non-existent entity
|
||||
$valid_job_id = $this->queue_processor->add_to_queue(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
99999, // Non-existent customer
|
||||
'create',
|
||||
'perfex_to_moloni',
|
||||
QueueProcessor::PRIORITY_NORMAL,
|
||||
['test' => 'error_handling']
|
||||
);
|
||||
|
||||
$this->assertNotFalse($valid_job_id, 'Valid job structure should be queued');
|
||||
|
||||
// Process should handle error gracefully
|
||||
$result = $this->queue_processor->process_queue(1, 30);
|
||||
|
||||
$this->assertEquals(1, $result['processed']);
|
||||
$this->assertEquals(0, $result['success']);
|
||||
$this->assertEquals(1, $result['errors']);
|
||||
|
||||
// Error should be logged and job should be retried or moved to dead letter
|
||||
$stats = $this->queue_processor->get_queue_statistics();
|
||||
$this->assertGreaterThanOrEqual(0, $stats['delayed'] + $stats['dead_letter']);
|
||||
}
|
||||
|
||||
public function testWebhookIntegration()
|
||||
{
|
||||
// Test webhook integration from Moloni
|
||||
|
||||
$webhook_data = [
|
||||
'entity_type' => EntityMappingService::ENTITY_CUSTOMER,
|
||||
'entity_id' => 5555, // Moloni customer ID
|
||||
'action' => 'update',
|
||||
'event_type' => 'customer.updated',
|
||||
'timestamp' => time(),
|
||||
'data' => [
|
||||
'customer_id' => 5555,
|
||||
'name' => 'Updated via Webhook',
|
||||
'email' => 'webhook@test.com'
|
||||
]
|
||||
];
|
||||
|
||||
// Trigger webhook handler
|
||||
$this->perfex_hooks->handle_moloni_webhook($webhook_data);
|
||||
|
||||
// Verify job was queued
|
||||
$stats = $this->queue_processor->get_queue_statistics();
|
||||
$initial_queued = $stats['pending_main'] + $stats['pending_priority'];
|
||||
|
||||
$this->assertGreaterThan(0, $initial_queued, 'Webhook should queue a job');
|
||||
|
||||
// Process the webhook job
|
||||
$result = $this->queue_processor->process_queue(1, 30);
|
||||
|
||||
$this->assertGreaterThanOrEqual(1, $result['processed']);
|
||||
}
|
||||
|
||||
public function testQueueHealthAndMonitoring()
|
||||
{
|
||||
// Test queue health monitoring
|
||||
|
||||
// Get initial health status
|
||||
$health = $this->queue_processor->health_check();
|
||||
|
||||
$this->assertArrayHasKey('status', $health);
|
||||
$this->assertArrayHasKey('checks', $health);
|
||||
$this->assertArrayHasKey('redis', $health['checks']);
|
||||
$this->assertArrayHasKey('dead_letter', $health['checks']);
|
||||
$this->assertArrayHasKey('processing', $health['checks']);
|
||||
$this->assertArrayHasKey('memory', $health['checks']);
|
||||
|
||||
// Test queue statistics
|
||||
$stats = $this->queue_processor->get_queue_statistics();
|
||||
|
||||
$this->assertArrayHasKey('pending_main', $stats);
|
||||
$this->assertArrayHasKey('pending_priority', $stats);
|
||||
$this->assertArrayHasKey('delayed', $stats);
|
||||
$this->assertArrayHasKey('processing', $stats);
|
||||
$this->assertArrayHasKey('dead_letter', $stats);
|
||||
$this->assertArrayHasKey('total_queued', $stats);
|
||||
$this->assertArrayHasKey('total_processed', $stats);
|
||||
$this->assertArrayHasKey('total_success', $stats);
|
||||
$this->assertArrayHasKey('total_errors', $stats);
|
||||
$this->assertArrayHasKey('success_rate', $stats);
|
||||
$this->assertArrayHasKey('memory_usage', $stats);
|
||||
|
||||
// Success rate should be a valid percentage
|
||||
$this->assertGreaterThanOrEqual(0, $stats['success_rate']);
|
||||
$this->assertLessThanOrEqual(100, $stats['success_rate']);
|
||||
}
|
||||
|
||||
private function cleanupTestData()
|
||||
{
|
||||
// Clean up any test data from previous runs
|
||||
// This would involve clearing test mappings, queue items, etc.
|
||||
|
||||
if (ENVIRONMENT !== 'production') {
|
||||
// Only clear in non-production environments
|
||||
try {
|
||||
$this->queue_processor->clear_all_queues();
|
||||
} catch (Exception $e) {
|
||||
// Queue might not be initialized yet, that's okay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Clean up after tests
|
||||
$this->cleanupTestData();
|
||||
|
||||
$this->client_sync = null;
|
||||
$this->product_sync = null;
|
||||
$this->invoice_sync = null;
|
||||
$this->estimate_sync = null;
|
||||
$this->queue_processor = null;
|
||||
$this->perfex_hooks = null;
|
||||
$this->entity_mapping = null;
|
||||
}
|
||||
}
|
||||
638
tests/QueueProcessorTest.php
Normal file
638
tests/QueueProcessorTest.php
Normal file
@@ -0,0 +1,638 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
367
tests/TestCase.php
Normal file
367
tests/TestCase.php
Normal file
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* TestCase.php
|
||||
*
|
||||
* Base test case for Desk-Moloni v3.0 integration tests
|
||||
* Provides common setup and utilities for database testing
|
||||
*
|
||||
* @package DeskMoloni\Tests
|
||||
* @author Database Design Specialist
|
||||
* @version 3.0
|
||||
*/
|
||||
|
||||
// Include PHPUnit if not already available
|
||||
if (!class_exists('PHPUnit\Framework\TestCase')) {
|
||||
// For older PHPUnit versions
|
||||
if (class_exists('PHPUnit_Framework_TestCase')) {
|
||||
class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase');
|
||||
} else {
|
||||
// Mock base class for development
|
||||
class PHPUnit_Framework_TestCase {
|
||||
protected function setUp() {}
|
||||
protected function tearDown() {}
|
||||
protected function assertTrue($condition, $message = '') {
|
||||
if (!$condition) {
|
||||
throw new Exception($message ?: 'Assertion failed');
|
||||
}
|
||||
}
|
||||
protected function assertFalse($condition, $message = '') {
|
||||
if ($condition) {
|
||||
throw new Exception($message ?: 'Assertion failed - expected false');
|
||||
}
|
||||
}
|
||||
protected function assertEquals($expected, $actual, $message = '') {
|
||||
if ($expected !== $actual) {
|
||||
throw new Exception($message ?: "Expected '$expected', got '$actual'");
|
||||
}
|
||||
}
|
||||
protected function assertNotNull($value, $message = '') {
|
||||
if ($value === null) {
|
||||
throw new Exception($message ?: 'Expected non-null value');
|
||||
}
|
||||
}
|
||||
protected function assertNull($value, $message = '') {
|
||||
if ($value !== null) {
|
||||
throw new Exception($message ?: 'Expected null value');
|
||||
}
|
||||
}
|
||||
protected function assertGreaterThan($expected, $actual, $message = '') {
|
||||
if ($actual <= $expected) {
|
||||
throw new Exception($message ?: "Expected $actual > $expected");
|
||||
}
|
||||
}
|
||||
protected function assertGreaterThanOrEqual($expected, $actual, $message = '') {
|
||||
if ($actual < $expected) {
|
||||
throw new Exception($message ?: "Expected $actual >= $expected");
|
||||
}
|
||||
}
|
||||
protected function assertLessThan($expected, $actual, $message = '') {
|
||||
if ($actual >= $expected) {
|
||||
throw new Exception($message ?: "Expected $actual < $expected");
|
||||
}
|
||||
}
|
||||
protected function assertLessThanOrEqual($expected, $actual, $message = '') {
|
||||
if ($actual > $expected) {
|
||||
throw new Exception($message ?: "Expected $actual <= $expected");
|
||||
}
|
||||
}
|
||||
protected function assertContains($needle, $haystack, $message = '') {
|
||||
if (!in_array($needle, $haystack)) {
|
||||
throw new Exception($message ?: "Expected array to contain '$needle'");
|
||||
}
|
||||
}
|
||||
protected function assertStringContainsString($needle, $haystack, $message = '') {
|
||||
if (strpos($haystack, $needle) === false) {
|
||||
throw new Exception($message ?: "Expected string to contain '$needle'");
|
||||
}
|
||||
}
|
||||
}
|
||||
class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase');
|
||||
}
|
||||
}
|
||||
|
||||
abstract class TestCase extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
protected $ci;
|
||||
protected $db;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Initialize CodeIgniter instance for testing
|
||||
$this->initializeCodeIgniter();
|
||||
|
||||
// Set up database connection
|
||||
$this->setupDatabase();
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
// Clean up any resources
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize CodeIgniter for testing
|
||||
*/
|
||||
protected function initializeCodeIgniter()
|
||||
{
|
||||
// Mock CodeIgniter instance for testing
|
||||
$this->ci = new stdClass();
|
||||
|
||||
// Mock database object
|
||||
$this->ci->db = $this->createDatabaseMock();
|
||||
|
||||
// Set global CI instance
|
||||
if (!function_exists('get_instance')) {
|
||||
function get_instance() {
|
||||
return $GLOBALS['CI_INSTANCE'];
|
||||
}
|
||||
}
|
||||
$GLOBALS['CI_INSTANCE'] = $this->ci;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create database mock for testing
|
||||
*/
|
||||
protected function createDatabaseMock()
|
||||
{
|
||||
return new DatabaseMock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up database connection and tables
|
||||
*/
|
||||
protected function setupDatabase()
|
||||
{
|
||||
$this->db = $this->ci->db;
|
||||
|
||||
// Ensure test tables exist
|
||||
$this->createTestTables();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test tables if they don't exist
|
||||
*/
|
||||
protected function createTestTables()
|
||||
{
|
||||
// This would normally create actual test tables
|
||||
// For now, we'll mock this functionality
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a raw SQL query for testing
|
||||
*/
|
||||
protected function executeRawSQL($sql)
|
||||
{
|
||||
return $this->db->query($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table structure information
|
||||
*/
|
||||
protected function getTableStructure($tableName)
|
||||
{
|
||||
return $this->db->field_data($tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up test data
|
||||
*/
|
||||
protected function cleanupTestData($tableName, $conditions = [])
|
||||
{
|
||||
$this->db->delete($tableName, $conditions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Database class for testing when real database is not available
|
||||
*/
|
||||
class DatabaseMock
|
||||
{
|
||||
private $lastQuery = '';
|
||||
private $lastError = ['code' => 0, 'message' => ''];
|
||||
private $insertSuccess = true;
|
||||
private $mockData = [];
|
||||
|
||||
public function table_exists($tableName)
|
||||
{
|
||||
// Mock table existence based on expected Desk-Moloni tables
|
||||
$expectedTables = [
|
||||
'desk_moloni_config',
|
||||
'desk_moloni_mapping',
|
||||
'desk_moloni_sync_queue',
|
||||
'desk_moloni_sync_log'
|
||||
];
|
||||
|
||||
return in_array($tableName, $expectedTables);
|
||||
}
|
||||
|
||||
public function field_exists($fieldName, $tableName)
|
||||
{
|
||||
// Mock field existence based on table structure
|
||||
$tableFields = [
|
||||
'desk_moloni_config' => [
|
||||
'id', 'setting_key', 'setting_value', 'encrypted', 'created_at', 'updated_at'
|
||||
],
|
||||
'desk_moloni_mapping' => [
|
||||
'id', 'entity_type', 'perfex_id', 'moloni_id', 'sync_direction',
|
||||
'last_sync_at', 'created_at', 'updated_at'
|
||||
],
|
||||
'desk_moloni_sync_queue' => [
|
||||
'id', 'task_type', 'entity_type', 'entity_id', 'priority', 'payload',
|
||||
'status', 'attempts', 'max_attempts', 'scheduled_at', 'started_at',
|
||||
'completed_at', 'error_message', 'created_at', 'updated_at'
|
||||
],
|
||||
'desk_moloni_sync_log' => [
|
||||
'id', 'operation_type', 'entity_type', 'perfex_id', 'moloni_id',
|
||||
'direction', 'status', 'request_data', 'response_data', 'error_message',
|
||||
'execution_time_ms', 'created_at'
|
||||
]
|
||||
];
|
||||
|
||||
return isset($tableFields[$tableName]) && in_array($fieldName, $tableFields[$tableName]);
|
||||
}
|
||||
|
||||
public function field_data($tableName)
|
||||
{
|
||||
// Mock field data structure
|
||||
$mockField = new stdClass();
|
||||
$mockField->name = 'id';
|
||||
$mockField->type = 'int';
|
||||
$mockField->primary_key = 1;
|
||||
|
||||
return [$mockField];
|
||||
}
|
||||
|
||||
public function insert($tableName, $data)
|
||||
{
|
||||
$this->lastQuery = "INSERT INTO {$tableName}";
|
||||
|
||||
// Mock insert validation
|
||||
if (isset($data['setting_key']) && $data['setting_key'] === 'test_unique_key') {
|
||||
static $inserted = false;
|
||||
if ($inserted) {
|
||||
$this->lastError = ['code' => 1062, 'message' => 'Duplicate entry'];
|
||||
return false;
|
||||
}
|
||||
$inserted = true;
|
||||
}
|
||||
|
||||
// Store mock data for retrieval
|
||||
$data['id'] = rand(1, 1000);
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||
$this->mockData[] = (object) $data;
|
||||
|
||||
return $this->insertSuccess;
|
||||
}
|
||||
|
||||
public function update($tableName, $data, $where = null)
|
||||
{
|
||||
$this->lastQuery = "UPDATE {$tableName}";
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete($tableName, $where = null)
|
||||
{
|
||||
$this->lastQuery = "DELETE FROM {$tableName}";
|
||||
return true;
|
||||
}
|
||||
|
||||
public function where($field, $value = null)
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function order_by($field, $direction = 'ASC')
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function limit($limit, $offset = null)
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function get($tableName = null)
|
||||
{
|
||||
$result = new stdClass();
|
||||
$result->row_array = [];
|
||||
$result->result_array = $this->mockData;
|
||||
$result->row = function() {
|
||||
return !empty($this->mockData) ? $this->mockData[0] : null;
|
||||
};
|
||||
$result->result = function() {
|
||||
return $this->mockData;
|
||||
};
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function count_all_results($tableName)
|
||||
{
|
||||
return count($this->mockData);
|
||||
}
|
||||
|
||||
public function query($sql)
|
||||
{
|
||||
$this->lastQuery = $sql;
|
||||
|
||||
// Mock specific queries
|
||||
if (strpos($sql, 'SHOW INDEX') !== false) {
|
||||
$mockIndexes = [
|
||||
['Key_name' => 'PRIMARY'],
|
||||
['Key_name' => 'idx_setting_key'],
|
||||
['Key_name' => 'idx_encrypted'],
|
||||
['Key_name' => 'idx_created_at']
|
||||
];
|
||||
|
||||
$result = new stdClass();
|
||||
$result->result_array = function() use ($mockIndexes) { return $mockIndexes; };
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (strpos($sql, 'TABLE_COLLATION') !== false) {
|
||||
$result = new stdClass();
|
||||
$result->row = function() {
|
||||
$row = new stdClass();
|
||||
$row->TABLE_COLLATION = 'utf8mb4_unicode_ci';
|
||||
return $row;
|
||||
};
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (strpos($sql, 'ENGINE') !== false) {
|
||||
$result = new stdClass();
|
||||
$result->row = function() {
|
||||
$row = new stdClass();
|
||||
$row->ENGINE = 'InnoDB';
|
||||
return $row;
|
||||
};
|
||||
return $result;
|
||||
}
|
||||
|
||||
return $this->get();
|
||||
}
|
||||
|
||||
public function error()
|
||||
{
|
||||
return $this->lastError;
|
||||
}
|
||||
|
||||
public function insert_id()
|
||||
{
|
||||
return rand(1, 1000);
|
||||
}
|
||||
|
||||
// Reset mock state
|
||||
public function reset()
|
||||
{
|
||||
$this->mockData = [];
|
||||
$this->lastError = ['code' => 0, 'message' => ''];
|
||||
$this->insertSuccess = true;
|
||||
}
|
||||
}
|
||||
407
tests/run_all_tests.php
Normal file
407
tests/run_all_tests.php
Normal file
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Comprehensive Test Runner for Desk-Moloni Synchronization System
|
||||
*
|
||||
* Executes all test suites and generates detailed reports
|
||||
* Tests bidirectional sync, queue processing, error handling, and performance
|
||||
*
|
||||
* @package DeskMoloni\Tests
|
||||
* @author Descomplicar® - PHP Fullstack Engineer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
// Prevent execution in production
|
||||
if (defined('ENVIRONMENT') && ENVIRONMENT === 'production') {
|
||||
exit('Tests cannot be run in production environment');
|
||||
}
|
||||
|
||||
// Set error reporting for comprehensive testing
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('memory_limit', '512M');
|
||||
set_time_limit(300); // 5 minutes
|
||||
|
||||
// Test configuration
|
||||
$test_config = [
|
||||
'run_unit_tests' => true,
|
||||
'run_integration_tests' => true,
|
||||
'run_performance_tests' => true,
|
||||
'generate_coverage' => false, // Set to true if XDebug is available
|
||||
'output_format' => 'html', // html, json, text
|
||||
'detailed_output' => true
|
||||
];
|
||||
|
||||
// Test results storage
|
||||
$test_results = [
|
||||
'start_time' => microtime(true),
|
||||
'total_tests' => 0,
|
||||
'passed_tests' => 0,
|
||||
'failed_tests' => 0,
|
||||
'skipped_tests' => 0,
|
||||
'test_suites' => [],
|
||||
'errors' => [],
|
||||
'warnings' => [],
|
||||
'performance_metrics' => [],
|
||||
'memory_usage' => []
|
||||
];
|
||||
|
||||
/**
|
||||
* Execute a test class and capture results
|
||||
*/
|
||||
function run_test_class($class_name, $test_file) {
|
||||
global $test_results;
|
||||
|
||||
$suite_start = microtime(true);
|
||||
$suite_results = [
|
||||
'class' => $class_name,
|
||||
'file' => $test_file,
|
||||
'tests' => [],
|
||||
'passed' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'execution_time' => 0,
|
||||
'memory_used' => 0,
|
||||
'errors' => []
|
||||
];
|
||||
|
||||
try {
|
||||
// Load test file
|
||||
if (!file_exists($test_file)) {
|
||||
throw new Exception("Test file not found: {$test_file}");
|
||||
}
|
||||
|
||||
require_once $test_file;
|
||||
|
||||
if (!class_exists($class_name)) {
|
||||
throw new Exception("Test class not found: {$class_name}");
|
||||
}
|
||||
|
||||
// Create test instance
|
||||
$test_instance = new $class_name();
|
||||
$reflection = new ReflectionClass($class_name);
|
||||
|
||||
// Get all test methods
|
||||
$methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC);
|
||||
$test_methods = array_filter($methods, function($method) {
|
||||
return strpos($method->getName(), 'test') === 0;
|
||||
});
|
||||
|
||||
echo "Running {$class_name} (" . count($test_methods) . " tests)...\n";
|
||||
|
||||
foreach ($test_methods as $method) {
|
||||
$test_name = $method->getName();
|
||||
$test_start = microtime(true);
|
||||
$test_memory_start = memory_get_usage(true);
|
||||
|
||||
try {
|
||||
// Setup
|
||||
if (method_exists($test_instance, 'setUp')) {
|
||||
$test_instance->setUp();
|
||||
}
|
||||
|
||||
// Execute test
|
||||
$method->invoke($test_instance);
|
||||
|
||||
// Test passed
|
||||
$suite_results['passed']++;
|
||||
$test_results['passed_tests']++;
|
||||
$status = 'PASSED';
|
||||
$error = null;
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Test failed
|
||||
$suite_results['failed']++;
|
||||
$test_results['failed_tests']++;
|
||||
$status = 'FAILED';
|
||||
$error = $e->getMessage();
|
||||
$suite_results['errors'][] = [
|
||||
'test' => $test_name,
|
||||
'error' => $error,
|
||||
'trace' => $e->getTraceAsString()
|
||||
];
|
||||
|
||||
} finally {
|
||||
// Teardown
|
||||
if (method_exists($test_instance, 'tearDown')) {
|
||||
try {
|
||||
$test_instance->tearDown();
|
||||
} catch (Exception $e) {
|
||||
// Teardown error
|
||||
$test_results['warnings'][] = "Teardown error in {$test_name}: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$test_execution_time = microtime(true) - $test_start;
|
||||
$test_memory_used = memory_get_usage(true) - $test_memory_start;
|
||||
|
||||
$suite_results['tests'][] = [
|
||||
'name' => $test_name,
|
||||
'status' => $status,
|
||||
'execution_time' => $test_execution_time,
|
||||
'memory_used' => $test_memory_used,
|
||||
'error' => $error
|
||||
];
|
||||
|
||||
$test_results['total_tests']++;
|
||||
|
||||
// Progress output
|
||||
echo " {$test_name}: {$status}";
|
||||
if ($test_execution_time > 1.0) {
|
||||
echo " (slow: " . number_format($test_execution_time, 2) . "s)";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$suite_results['errors'][] = [
|
||||
'test' => 'Suite Setup',
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
];
|
||||
$test_results['errors'][] = "Error in {$class_name}: " . $e->getMessage();
|
||||
}
|
||||
|
||||
$suite_results['execution_time'] = microtime(true) - $suite_start;
|
||||
$suite_results['memory_used'] = memory_get_peak_usage(true);
|
||||
$test_results['test_suites'][] = $suite_results;
|
||||
|
||||
echo " Completed in " . number_format($suite_results['execution_time'], 2) . "s\n\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate test report
|
||||
*/
|
||||
function generate_test_report($format = 'html') {
|
||||
global $test_results;
|
||||
|
||||
$test_results['end_time'] = microtime(true);
|
||||
$test_results['total_execution_time'] = $test_results['end_time'] - $test_results['start_time'];
|
||||
$test_results['peak_memory'] = memory_get_peak_usage(true);
|
||||
$test_results['success_rate'] = $test_results['total_tests'] > 0 ?
|
||||
($test_results['passed_tests'] / $test_results['total_tests']) * 100 : 0;
|
||||
|
||||
switch ($format) {
|
||||
case 'html':
|
||||
return generate_html_report();
|
||||
case 'json':
|
||||
return json_encode($test_results, JSON_PRETTY_PRINT);
|
||||
case 'text':
|
||||
default:
|
||||
return generate_text_report();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML report
|
||||
*/
|
||||
function generate_html_report() {
|
||||
global $test_results;
|
||||
|
||||
$html = '<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Desk-Moloni Test Report</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.header { background: #f8f9fa; padding: 20px; border-radius: 5px; margin-bottom: 20px; }
|
||||
.summary { display: flex; gap: 20px; margin-bottom: 20px; }
|
||||
.metric { background: #e9ecef; padding: 15px; border-radius: 5px; text-align: center; flex: 1; }
|
||||
.metric.passed { background: #d4edda; }
|
||||
.metric.failed { background: #f8d7da; }
|
||||
.suite { border: 1px solid #dee2e6; margin-bottom: 20px; border-radius: 5px; }
|
||||
.suite-header { background: #f8f9fa; padding: 10px; font-weight: bold; }
|
||||
.test { padding: 10px; border-bottom: 1px solid #dee2e6; }
|
||||
.test.passed { background: #f8fffa; }
|
||||
.test.failed { background: #fff8f8; }
|
||||
.error { background: #f8d7da; padding: 10px; margin: 10px 0; border-radius: 3px; }
|
||||
.performance { background: #fff3cd; padding: 10px; margin: 10px 0; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>';
|
||||
|
||||
$html .= '<div class="header">';
|
||||
$html .= '<h1>🐘 Desk-Moloni Synchronization System Test Report</h1>';
|
||||
$html .= '<p>Generated on: ' . date('Y-m-d H:i:s') . '</p>';
|
||||
$html .= '<p>Environment: ' . (defined('ENVIRONMENT') ? ENVIRONMENT : 'development') . '</p>';
|
||||
$html .= '</div>';
|
||||
|
||||
// Summary metrics
|
||||
$html .= '<div class="summary">';
|
||||
$html .= '<div class="metric">';
|
||||
$html .= '<h3>' . $test_results['total_tests'] . '</h3>';
|
||||
$html .= '<p>Total Tests</p>';
|
||||
$html .= '</div>';
|
||||
$html .= '<div class="metric passed">';
|
||||
$html .= '<h3>' . $test_results['passed_tests'] . '</h3>';
|
||||
$html .= '<p>Passed</p>';
|
||||
$html .= '</div>';
|
||||
$html .= '<div class="metric failed">';
|
||||
$html .= '<h3>' . $test_results['failed_tests'] . '</h3>';
|
||||
$html .= '<p>Failed</p>';
|
||||
$html .= '</div>';
|
||||
$html .= '<div class="metric">';
|
||||
$html .= '<h3>' . number_format($test_results['success_rate'], 1) . '%</h3>';
|
||||
$html .= '<p>Success Rate</p>';
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
// Execution metrics
|
||||
$html .= '<div class="performance">';
|
||||
$html .= '<h3>Performance Metrics</h3>';
|
||||
$html .= '<p><strong>Total Execution Time:</strong> ' . number_format($test_results['total_execution_time'], 2) . ' seconds</p>';
|
||||
$html .= '<p><strong>Peak Memory Usage:</strong> ' . number_format($test_results['peak_memory'] / 1024 / 1024, 2) . ' MB</p>';
|
||||
$html .= '</div>';
|
||||
|
||||
// Test suites
|
||||
foreach ($test_results['test_suites'] as $suite) {
|
||||
$html .= '<div class="suite">';
|
||||
$html .= '<div class="suite-header">';
|
||||
$html .= $suite['class'] . ' (' . count($suite['tests']) . ' tests, ';
|
||||
$html .= $suite['passed'] . ' passed, ' . $suite['failed'] . ' failed)';
|
||||
$html .= ' - ' . number_format($suite['execution_time'], 2) . 's';
|
||||
$html .= '</div>';
|
||||
|
||||
foreach ($suite['tests'] as $test) {
|
||||
$html .= '<div class="test ' . strtolower($test['status']) . '">';
|
||||
$html .= '<strong>' . $test['name'] . '</strong> - ' . $test['status'];
|
||||
$html .= ' (' . number_format($test['execution_time'], 3) . 's)';
|
||||
if ($test['error']) {
|
||||
$html .= '<div class="error">' . htmlspecialchars($test['error']) . '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
if (!empty($suite['errors'])) {
|
||||
foreach ($suite['errors'] as $error) {
|
||||
$html .= '<div class="error">';
|
||||
$html .= '<strong>Error in ' . $error['test'] . ':</strong><br>';
|
||||
$html .= htmlspecialchars($error['error']);
|
||||
$html .= '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
// Global errors
|
||||
if (!empty($test_results['errors'])) {
|
||||
$html .= '<div class="suite">';
|
||||
$html .= '<div class="suite-header">Global Errors</div>';
|
||||
foreach ($test_results['errors'] as $error) {
|
||||
$html .= '<div class="error">' . htmlspecialchars($error) . '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
$html .= '</body></html>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text report
|
||||
*/
|
||||
function generate_text_report() {
|
||||
global $test_results;
|
||||
|
||||
$output = "\n";
|
||||
$output .= "============================================\n";
|
||||
$output .= "🐘 DESK-MOLONI SYNCHRONIZATION TEST REPORT\n";
|
||||
$output .= "============================================\n\n";
|
||||
|
||||
$output .= "Generated: " . date('Y-m-d H:i:s') . "\n";
|
||||
$output .= "Environment: " . (defined('ENVIRONMENT') ? ENVIRONMENT : 'development') . "\n\n";
|
||||
|
||||
$output .= "SUMMARY:\n";
|
||||
$output .= "--------\n";
|
||||
$output .= "Total Tests: " . $test_results['total_tests'] . "\n";
|
||||
$output .= "Passed: " . $test_results['passed_tests'] . "\n";
|
||||
$output .= "Failed: " . $test_results['failed_tests'] . "\n";
|
||||
$output .= "Success Rate: " . number_format($test_results['success_rate'], 1) . "%\n";
|
||||
$output .= "Execution Time: " . number_format($test_results['total_execution_time'], 2) . " seconds\n";
|
||||
$output .= "Peak Memory: " . number_format($test_results['peak_memory'] / 1024 / 1024, 2) . " MB\n\n";
|
||||
|
||||
foreach ($test_results['test_suites'] as $suite) {
|
||||
$output .= "TEST SUITE: " . $suite['class'] . "\n";
|
||||
$output .= str_repeat("-", strlen("TEST SUITE: " . $suite['class'])) . "\n";
|
||||
|
||||
foreach ($suite['tests'] as $test) {
|
||||
$status_symbol = $test['status'] === 'PASSED' ? '✓' : '✗';
|
||||
$output .= "{$status_symbol} {$test['name']} ({$test['status']})";
|
||||
if ($test['execution_time'] > 1.0) {
|
||||
$output .= " [SLOW: " . number_format($test['execution_time'], 2) . "s]";
|
||||
}
|
||||
$output .= "\n";
|
||||
|
||||
if ($test['error']) {
|
||||
$output .= " Error: " . $test['error'] . "\n";
|
||||
}
|
||||
}
|
||||
$output .= "\n";
|
||||
}
|
||||
|
||||
if (!empty($test_results['errors'])) {
|
||||
$output .= "GLOBAL ERRORS:\n";
|
||||
$output .= "--------------\n";
|
||||
foreach ($test_results['errors'] as $error) {
|
||||
$output .= "• " . $error . "\n";
|
||||
}
|
||||
$output .= "\n";
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
// Main execution
|
||||
echo "🐘 Starting Desk-Moloni Synchronization System Tests...\n";
|
||||
echo "========================================================\n\n";
|
||||
|
||||
// Define test suites
|
||||
$test_suites = [];
|
||||
|
||||
if ($test_config['run_unit_tests']) {
|
||||
$test_suites = array_merge($test_suites, [
|
||||
'ClientSyncServiceTest' => __DIR__ . '/ClientSyncServiceTest.php',
|
||||
'QueueProcessorTest' => __DIR__ . '/QueueProcessorTest.php'
|
||||
]);
|
||||
}
|
||||
|
||||
if ($test_config['run_integration_tests']) {
|
||||
$test_suites['IntegrationTest'] = __DIR__ . '/IntegrationTest.php';
|
||||
}
|
||||
|
||||
// Execute test suites
|
||||
foreach ($test_suites as $class_name => $test_file) {
|
||||
run_test_class($class_name, $test_file);
|
||||
}
|
||||
|
||||
// Generate and output report
|
||||
echo "\n" . str_repeat("=", 50) . "\n";
|
||||
echo "GENERATING TEST REPORT...\n";
|
||||
echo str_repeat("=", 50) . "\n";
|
||||
|
||||
$report = generate_test_report($test_config['output_format']);
|
||||
|
||||
if ($test_config['output_format'] === 'html') {
|
||||
$report_file = __DIR__ . '/test_report_' . date('Y-m-d_H-i-s') . '.html';
|
||||
file_put_contents($report_file, $report);
|
||||
echo "HTML report saved to: {$report_file}\n";
|
||||
}
|
||||
|
||||
echo $report;
|
||||
|
||||
// Exit with appropriate code
|
||||
$exit_code = $test_results['failed_tests'] > 0 ? 1 : 0;
|
||||
echo "\nTest execution completed with exit code: {$exit_code}\n";
|
||||
|
||||
if ($exit_code === 0) {
|
||||
echo "🎉 All tests passed!\n";
|
||||
} else {
|
||||
echo "❌ Some tests failed. Please review the results above.\n";
|
||||
}
|
||||
|
||||
exit($exit_code);
|
||||
Reference in New Issue
Block a user