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:
Emanuel Almeida
2025-09-11 17:38:45 +01:00
parent 5e5102db73
commit c19f6fd9ee
193 changed files with 59298 additions and 638 deletions

467
tests/ClientPortalTest.php Normal file
View 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");
}
}
}

View 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
View 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;
}
}

View 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
View 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
View 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);