diff --git a/Civi/Financeextras/Common/GCManager.php b/Civi/Financeextras/Common/GCManager.php new file mode 100644 index 00000000..7eaec25e --- /dev/null +++ b/Civi/Financeextras/Common/GCManager.php @@ -0,0 +1,200 @@ + 0, + 'effective_calls' => 0, + 'last_effective_call' => 0, + // Start conservative. + 'interval' => 1000, + 'memory_threshold' => 0, + ]; + + /** + * Whether the GC manager has been initialized. + * + * @var bool + */ + private static $initialized = FALSE; + + /** + * Initialize GC manager with safe defaults + */ + public static function init() { + if (!self::$initialized) { + if (function_exists('gc_enable')) { + // Enable GC at start + gc_enable(); + } + + // Set memory threshold to 75% of memory limit or 200MB minimum + $memoryLimit = self::parseMemoryLimit(ini_get('memory_limit')); + self::$gcStats['memory_threshold'] = max( + $memoryLimit * 0.75, + // 200MB minimum. + 200 * 1024 * 1024 + ); + + self::$initialized = TRUE; + } + } + + /** + * Intelligently decide whether to collect garbage + * + * @param string $operationType Type of operation for tracking + * @return bool TRUE if GC was performed + */ + public static function maybeCollectGarbage($operationType = 'default') { + self::init(); + + static $counters = []; + if (!isset($counters[$operationType])) { + $counters[$operationType] = 0; + } + + $counters[$operationType]++; + + // Multiple trigger conditions + $shouldCollect = FALSE; + $reason = ''; + + // 1. Batch-complete trigger (after each invoice) + if ($operationType === 'invoice_processing') { + $shouldCollect = TRUE; + $reason = 'batch_complete'; + } + // 2. Iteration-count trigger (adaptive) for other operations + elseif ($counters[$operationType] >= self::$gcStats['interval']) { + $shouldCollect = TRUE; + $reason = 'iteration_count'; + $counters[$operationType] = 0; + } + + // 3. Memory-threshold trigger (always check) + $currentMemory = memory_get_usage(TRUE); + if ($currentMemory > self::$gcStats['memory_threshold']) { + $shouldCollect = TRUE; + $reason = 'memory_threshold'; + $counters[$operationType] = 0; + } + + if ($shouldCollect && function_exists('gc_collect_cycles')) { + $beforeMemory = memory_get_usage(TRUE); + $startTime = microtime(TRUE); + + $cycles = gc_collect_cycles(); + + // ms. + $duration = (microtime(TRUE) - $startTime) * 1000; + $afterMemory = memory_get_usage(TRUE); + $memoryFreed = $beforeMemory - $afterMemory; + + self::$gcStats['calls']++; + + if ($cycles > 0) { + self::$gcStats['effective_calls']++; + self::$gcStats['last_effective_call'] = self::$gcStats['calls']; + } + + // Adaptive interval adjustment + self::adjustInterval($cycles, $reason); + + // Log significant collections only. + // 5MB. + if ($cycles > 0 || $memoryFreed > (5 * 1024 * 1024)) { + \Civi::log()->debug(sprintf( + 'FinanceExtras GC (%s): %d cycles, %.2fms, %s freed, memory: %s, interval: %d', + $reason, + $cycles, + $duration, + self::formatBytes($memoryFreed), + self::formatBytes($afterMemory), + self::$gcStats['interval'] + )); + } + + return TRUE; + } + + return FALSE; + } + + /** + * Adjust GC interval based on effectiveness + */ + private static function adjustInterval($cycles, $reason) { + $callsSinceEffective = self::$gcStats['calls'] - self::$gcStats['last_effective_call']; + + // If GC returned 0 repeatedly, increase interval (less frequent calls) + if ($cycles === 0 && $callsSinceEffective >= 3) { + self::$gcStats['interval'] = min(self::$gcStats['interval'] * 1.5, 5000); + } + + // If GC was effective, maintain or slightly reduce interval + if ($cycles > 0) { + // Many cycles collected - memory pressure exists. + if ($cycles > 10) { + self::$gcStats['interval'] = max(self::$gcStats['interval'] * 0.8, 500); + } + } + } + + /** + * Parse PHP memory limit string to bytes + */ + private static function parseMemoryLimit($limit) { + if ($limit === '-1') { + return PHP_INT_MAX; + } + + $value = (int) $limit; + $unit = strtolower(substr($limit, -1)); + + switch ($unit) { + case 'g': + return $value * 1024 * 1024 * 1024; + + case 'm': + return $value * 1024 * 1024; + + case 'k': + return $value * 1024; + + default: + return $value; + } + } + + /** + * Format bytes for human-readable logging + */ + private static function formatBytes($bytes) { + if ($bytes >= 1073741824) { + return number_format($bytes / 1073741824, 2) . ' GB'; + } + if ($bytes >= 1048576) { + return number_format($bytes / 1048576, 2) . ' MB'; + } + if ($bytes >= 1024) { + return number_format($bytes / 1024, 2) . ' KB'; + } + return $bytes . ' B'; + } + + /** + * Get GC statistics for monitoring + */ + public static function getStats() { + return self::$gcStats; + } + +} diff --git a/Civi/Financeextras/Hook/AlterMailParams/InvoiceTemplate.php b/Civi/Financeextras/Hook/AlterMailParams/InvoiceTemplate.php index 67bec35c..4374915e 100644 --- a/Civi/Financeextras/Hook/AlterMailParams/InvoiceTemplate.php +++ b/Civi/Financeextras/Hook/AlterMailParams/InvoiceTemplate.php @@ -4,6 +4,7 @@ use Civi\WorkflowMessage\WorkflowMessage; use CRM_Financeextras_CustomGroup_ContributionOwnerOrganisation as ContributionOwnerOrganisation; +use Civi\Financeextras\Common\GCManager; /** * Provides separate invoicing template and tokens for each @@ -17,35 +18,71 @@ class InvoiceTemplate { private $contributionOwnerCompany; + private static $processedInvoices = 0; + private static $contributionCache = []; + private static $contributionCacheOrder = []; + private static $ownerCompanyCache = []; + private static $ownerCompanyCacheOrder = []; + private static $locationCache = []; + private static $locationCacheOrder = []; + private static $maxCacheSize = 100; + public function __construct(&$templateParams, $context) { $this->templateParams = &$templateParams; $this->contributionId = $templateParams['tplParams']['id']; } public function handle() { - $this->addTaxConversionTable(); + self::$processedInvoices++; - $this->contributionOwnerCompany = ContributionOwnerOrganisation::getOwnerOrganisationCompany($this->contributionId); - if (empty($this->contributionOwnerCompany)) { - return; - } + try { + $this->addTaxConversionTable(); + + // Get owner company from cache or fetch + $this->contributionOwnerCompany = $this->getOwnerCompanyFromCache($this->contributionId); + if (!$this->contributionOwnerCompany) { + $this->contributionOwnerCompany = ContributionOwnerOrganisation::getOwnerOrganisationCompany($this->contributionId); + // Cache using LRU + $this->addToLRUCache(self::$ownerCompanyCache, self::$ownerCompanyCacheOrder, $this->contributionId, $this->contributionOwnerCompany); + } + + if (empty($this->contributionOwnerCompany)) { + return; + } + + $this->useContributionOwnerOrganisationInvoiceTemplate(); + $this->replaceDomainTokensWithOwnerOrganisationTokens(); - $this->useContributionOwnerOrganisationInvoiceTemplate(); - $this->replaceDomainTokensWithOwnerOrganisationTokens(); + // Adaptive memory management: Batch-complete trigger after each invoice + // Uses conservative approach with memory-threshold backup + GCManager::maybeCollectGarbage('invoice_processing'); + } catch (Exception $e) { + // Log error and continue processing other invoices + \Civi::log()->error('InvoiceTemplate processing failed for contribution ' . $this->contributionId . ': ' . $e->getMessage()); + throw $e; + } } private function addTaxConversionTable() { $showTaxConversionTable = TRUE; - $contribution = \Civi\Api4\Contribution::get(FALSE) - ->addSelect( - 'financeextras_currency_exchange_rates.rate_1_unit_tax_currency', - 'financeextras_currency_exchange_rates.rate_1_unit_contribution_currency', - 'financeextras_currency_exchange_rates.sales_tax_currency', - 'financeextras_currency_exchange_rates.vat_text' - )->setLimit(1) - ->addWhere('id', '=', $this->contributionId) - ->execute() - ->first(); + + // Check LRU cache first + $contribution = $this->getContributionFromCache($this->contributionId); + if (!$contribution) { + $contribution = \Civi\Api4\Contribution::get(FALSE) + ->addSelect( + 'financeextras_currency_exchange_rates.rate_1_unit_tax_currency', + 'financeextras_currency_exchange_rates.rate_1_unit_contribution_currency', + 'financeextras_currency_exchange_rates.sales_tax_currency', + 'financeextras_currency_exchange_rates.vat_text' + )->setLimit(1) + ->addWhere('id', '=', $this->contributionId) + ->execute() + ->first(); + + // Cache the result using LRU + $this->addToLRUCache(self::$contributionCache, self::$contributionCacheOrder, $this->contributionId, $contribution); + } if (empty($contribution['financeextras_currency_exchange_rates.rate_1_unit_tax_currency'])) { $showTaxConversionTable = FALSE; } @@ -137,7 +174,14 @@ private function replaceDomainTokensWithOwnerOrganisationTokens() { */ private function getOwnerOrganisationLocation() { $ownerOrganisationId = $this->contributionOwnerCompany['contact_id']; - $locationDefaults = \CRM_Core_BAO_Location::getValues(['contact_id' => $ownerOrganisationId]); + + // Check LRU cache first + $locationDefaults = $this->getLocationFromCache($ownerOrganisationId); + if (!$locationDefaults) { + $locationDefaults = \CRM_Core_BAO_Location::getValues(['contact_id' => $ownerOrganisationId]); + // Cache using LRU + $this->addToLRUCache(self::$locationCache, self::$locationCacheOrder, $ownerOrganisationId, $locationDefaults); + } if (!empty($locationDefaults['address'][1]['state_province_id'])) { $locationDefaults['address'][1]['state_province_abbreviation'] = \CRM_Core_PseudoConstant::stateProvinceAbbreviation($locationDefaults['address'][1]['state_province_id']); @@ -156,4 +200,71 @@ private function getOwnerOrganisationLocation() { return $locationDefaults; } + /** + * Gets contribution data from LRU cache. + */ + private function getContributionFromCache($contributionId) { + if (isset(self::$contributionCache[$contributionId])) { + $this->updateLRUOrder(self::$contributionCacheOrder, $contributionId); + return self::$contributionCache[$contributionId]; + } + return FALSE; + } + + /** + * Gets owner company data from LRU cache. + */ + private function getOwnerCompanyFromCache($contributionId) { + if (isset(self::$ownerCompanyCache[$contributionId])) { + $this->updateLRUOrder(self::$ownerCompanyCacheOrder, $contributionId); + return self::$ownerCompanyCache[$contributionId]; + } + return FALSE; + } + + /** + * Gets location data from LRU cache. + */ + private function getLocationFromCache($contactId) { + if (isset(self::$locationCache[$contactId])) { + $this->updateLRUOrder(self::$locationCacheOrder, $contactId); + return self::$locationCache[$contactId]; + } + return FALSE; + } + + /** + * Updates LRU order by moving item to end (most recently used). + */ + private function updateLRUOrder(&$orderArray, $key) { + $index = array_search($key, $orderArray); + if ($index !== FALSE) { + unset($orderArray[$index]); + $orderArray = array_values($orderArray); // Re-index array + } + $orderArray[] = $key; + } + + /** + * Adds item to LRU cache, evicting least recently used if at capacity. + */ + private function addToLRUCache(&$cache, &$orderArray, $key, $value) { + // If already exists, update value and move to end + if (isset($cache[$key])) { + $cache[$key] = $value; + $this->updateLRUOrder($orderArray, $key); + return; + } + + // If at capacity, remove least recently used item + if (count($cache) >= self::$maxCacheSize) { + $lruKey = array_shift($orderArray); + unset($cache[$lruKey]); + } + + // Add new item + $cache[$key] = $value; + $orderArray[] = $key; + } + } diff --git a/bin/install-php-linter b/bin/install-php-linter deleted file mode 100755 index 7b6a5e2f..00000000 --- a/bin/install-php-linter +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -set -e - -# Download PHPCS if it already does not exist -if [ ! -f phpcs.phar ]; then - curl -OL https://squizlabs.github.io/PHP_CodeSniffer/phpcs.phar -fi -# Give executable permission to PHPCS -chmod +x phpcs.phar - -# Download PHPCBF if it already does not exist -if [ ! -f phpcbf.phar ]; then - curl -OL https://squizlabs.github.io/PHP_CodeSniffer/phpcbf.phar -fi - -# Give executable permission to PHPCBF -chmod +x phpcbf.phar - -# Clone CiviCRM Coder repo -if [ ! -d drupal/coder ]; then - git clone --depth 1 https://github.com/civicrm/coder.git civicrm/coder -fi diff --git a/financeextras.php b/financeextras.php index 3e4364d6..d9156bf8 100644 --- a/financeextras.php +++ b/financeextras.php @@ -229,9 +229,13 @@ function financeextras_civicrm_alterMailParams(&$params, $context) { foreach ($hooks as $hook) { if ($hook::shouldHandle($params, $context)) { - (new $hook($params, $context))->handle(); + $hookInstance = new $hook($params, $context); + $hookInstance->handle(); } } + + // Adaptive memory management for mail processing operations + \Civi\Financeextras\Common\GCManager::maybeCollectGarbage('mail_processing'); } /** diff --git a/tests/phpunit/Civi/Financeextras/Hook/AlterMailParams/InvoiceTemplateMemoryTest.php b/tests/phpunit/Civi/Financeextras/Hook/AlterMailParams/InvoiceTemplateMemoryTest.php new file mode 100644 index 00000000..bd2eb011 --- /dev/null +++ b/tests/phpunit/Civi/Financeextras/Hook/AlterMailParams/InvoiceTemplateMemoryTest.php @@ -0,0 +1,365 @@ +getProperty('processedInvoices'); + $property->setAccessible(TRUE); + $property->setValue(NULL, 0); + + // Mock Civi log to capture logging calls + if (!class_exists('\Civi')) { + $this->createCiviMock(); + } + } + + /** + * Test that processed invoices counter increments correctly. + */ + public function testProcessedInvoicesCounterIncrement(): void { + $templateParams = [ + 'tplParams' => ['id' => 123] + ]; + + // Mock ContributionOwnerOrganisation to return empty (early return) + if (!class_exists('ContributionOwnerOrganisation')) { + $this->mockContributionOwnerOrganisation(); + } + + $invoice1 = new InvoiceTemplate($templateParams, 'test'); + $invoice2 = new InvoiceTemplate($templateParams, 'test'); + + // Get initial counter value + $reflection = new \ReflectionClass(InvoiceTemplate::class); + $property = $reflection->getProperty('processedInvoices'); + $property->setAccessible(TRUE); + $initialCount = $property->getValue(); + + // Process invoices + $invoice1->handle(); + $this->assertEquals($initialCount + 1, $property->getValue()); + + $invoice2->handle(); + $this->assertEquals($initialCount + 2, $property->getValue()); + } + + /** + * Test garbage collection is triggered at correct intervals. + */ + public function testGarbageCollectionTriggering(): void { + $templateParams = [ + 'tplParams' => ['id' => 123] + ]; + + if (!class_exists('ContributionOwnerOrganisation')) { + $this->mockContributionOwnerOrganisation(); + } + + // Mock gc_collect_cycles to track calls + $gcCalled = FALSE; + if (!function_exists('gc_collect_cycles')) { + function gc_collect_cycles() { + global $gcCalled; + $gcCalled = TRUE; + return 0; + }; + } + + // Process exactly 25 invoices to trigger GC + for ($i = 1; $i <= 25; $i++) { + $invoice = new InvoiceTemplate($templateParams, 'test'); + $invoice->handle(); + } + + // GC should have been called on the 25th invoice + $this->assertTrue($gcCalled, 'Garbage collection should be triggered after 25 processed invoices'); + } + + /** + * Test error handling and logging functionality. + */ + public function testErrorHandlingAndLogging(): void { + $templateParams = [ + 'tplParams' => ['id' => 123] + ]; + + // Create a mock that throws an exception + $invoice = $this->getMockBuilder(InvoiceTemplate::class) + ->setConstructorArgs([$templateParams, 'test']) + ->onlyMethods(['addTaxConversionTable']) + ->getMock(); + + $invoice->expects($this->once()) + ->method('addTaxConversionTable') + ->willThrowException(new Exception('Test exception')); + + // Expect the exception to be re-thrown after logging + $this->expectException(Exception::class); + $this->expectExceptionMessage('Test exception'); + + $invoice->handle(); + } + + /** + * Test memory usage remains reasonable during bulk processing. + */ + public function testBulkProcessingMemoryUsage(): void { + $templateParams = [ + 'tplParams' => ['id' => 123] + ]; + + if (!class_exists('ContributionOwnerOrganisation')) { + $this->mockContributionOwnerOrganisation(); + } + + $startMemory = memory_get_usage(TRUE); + + // Process 50 invoices (2 GC cycles) + for ($i = 1; $i <= 50; $i++) { + $invoice = new InvoiceTemplate($templateParams, 'test'); + $invoice->handle(); + } + + $endMemory = memory_get_usage(TRUE); + $memoryIncrease = ($endMemory - $startMemory) / (1024 * 1024); // MB + + // Memory increase should be reasonable (under 10MB for 50 invoices) + $this->assertLessThan(10, $memoryIncrease, + 'Memory usage should remain bounded during bulk processing'); + } + + /** + * Test that static counter persists across multiple instances. + */ + public function testStaticCounterPersistence(): void { + $templateParams = [ + 'tplParams' => ['id' => 123] + ]; + + if (!class_exists('ContributionOwnerOrganisation')) { + $this->mockContributionOwnerOrganisation(); + } + + // Create and process multiple instances + $instances = []; + for ($i = 0; $i < 5; $i++) { + $instances[] = new InvoiceTemplate($templateParams, 'test'); + } + + foreach ($instances as $index => $instance) { + $instance->handle(); + + // Check counter after each processing + $reflection = new \ReflectionClass(InvoiceTemplate::class); + $property = $reflection->getProperty('processedInvoices'); + $property->setAccessible(TRUE); + + $this->assertEquals($index + 1, $property->getValue(), + 'Static counter should persist across instances'); + } + } + + /** + * Mock ContributionOwnerOrganisation for testing. + */ + private function mockContributionOwnerOrganisation(): void { + if (!class_exists('ContributionOwnerOrganisation')) { + eval(' + class ContributionOwnerOrganisation { + public static function getOwnerOrganisationCompany($contributionId) { + return NULL; // Return empty to trigger early return + } + } + '); + } + } + + /** + * Test LRU cache functionality for contribution data. + */ + public function testContributionLRUCache(): void { + $templateParams = ['tplParams' => ['id' => 123]]; + $invoice = new InvoiceTemplate($templateParams, 'test'); + $reflection = new \ReflectionClass($invoice); + + // Get private methods + $addToLRUCacheMethod = $reflection->getMethod('addToLRUCache'); + $addToLRUCacheMethod->setAccessible(TRUE); + $getContributionFromCacheMethod = $reflection->getMethod('getContributionFromCache'); + $getContributionFromCacheMethod->setAccessible(TRUE); + + // Test cache miss + $result = $getContributionFromCacheMethod->invoke($invoice, 999); + $this->assertFalse($result); + + // Add to cache and test hit + $cache = []; + $order = []; + $testData = ['id' => 123, 'rate_1_unit_tax_currency' => '1.2']; + + $addToLRUCacheMethod->invoke($invoice, $cache, $order, 123, $testData); + + // Set static cache properties + $contributionCacheProperty = $reflection->getProperty('contributionCache'); + $contributionCacheProperty->setAccessible(TRUE); + $contributionCacheProperty->setValue(NULL, $cache); + + $contributionOrderProperty = $reflection->getProperty('contributionCacheOrder'); + $contributionOrderProperty->setAccessible(TRUE); + $contributionOrderProperty->setValue(NULL, $order); + + $result = $getContributionFromCacheMethod->invoke($invoice, 123); + $this->assertEquals($testData, $result); + } + + /** + * Test LRU cache eviction when at capacity. + */ + public function testLRUCacheEviction(): void { + $templateParams = ['tplParams' => ['id' => 1]]; + $invoice = new InvoiceTemplate($templateParams, 'test'); + $reflection = new \ReflectionClass($invoice); + + $addToLRUCacheMethod = $reflection->getMethod('addToLRUCache'); + $addToLRUCacheMethod->setAccessible(TRUE); + $maxCacheSizeProperty = $reflection->getProperty('maxCacheSize'); + $maxCacheSizeProperty->setAccessible(TRUE); + + // Set small cache size for testing + $maxCacheSizeProperty->setValue(NULL, 2); + + $cache = []; + $order = []; + + // Fill cache to capacity + $addToLRUCacheMethod->invoke($invoice, $cache, $order, 1, 'data1'); + $addToLRUCacheMethod->invoke($invoice, $cache, $order, 2, 'data2'); + + $this->assertCount(2, $cache); + $this->assertTrue(isset($cache[1])); + + // Add one more - should evict LRU + $addToLRUCacheMethod->invoke($invoice, $cache, $order, 3, 'data3'); + + $this->assertCount(2, $cache); + $this->assertFalse(isset($cache[1])); // First item evicted + $this->assertTrue(isset($cache[3])); // New item present + } + + /** + * Test owner company cache functionality. + */ + public function testOwnerCompanyCache(): void { + $templateParams = ['tplParams' => ['id' => 456]]; + $invoice = new InvoiceTemplate($templateParams, 'test'); + $reflection = new \ReflectionClass($invoice); + + $getOwnerCompanyFromCacheMethod = $reflection->getMethod('getOwnerCompanyFromCache'); + $getOwnerCompanyFromCacheMethod->setAccessible(TRUE); + + // Test cache miss + $result = $getOwnerCompanyFromCacheMethod->invoke($invoice, 456); + $this->assertFalse($result); + + // Add to cache + $addToLRUCacheMethod = $reflection->getMethod('addToLRUCache'); + $addToLRUCacheMethod->setAccessible(TRUE); + + $cache = []; + $order = []; + $ownerData = ['contact_id' => 789, 'name' => 'Test Company']; + + $addToLRUCacheMethod->invoke($invoice, $cache, $order, 456, $ownerData); + + // Set static properties + $ownerCacheProperty = $reflection->getProperty('ownerCompanyCache'); + $ownerCacheProperty->setAccessible(TRUE); + $ownerCacheProperty->setValue(NULL, $cache); + + $ownerOrderProperty = $reflection->getProperty('ownerCompanyCacheOrder'); + $ownerOrderProperty->setAccessible(TRUE); + $ownerOrderProperty->setValue(NULL, $order); + + // Test cache hit + $result = $getOwnerCompanyFromCacheMethod->invoke($invoice, 456); + $this->assertEquals($ownerData, $result); + } + + /** + * Test location cache functionality. + */ + public function testLocationCache(): void { + $templateParams = ['tplParams' => ['id' => 789]]; + $invoice = new InvoiceTemplate($templateParams, 'test'); + $reflection = new \ReflectionClass($invoice); + + $getLocationFromCacheMethod = $reflection->getMethod('getLocationFromCache'); + $getLocationFromCacheMethod->setAccessible(TRUE); + + // Test cache miss + $result = $getLocationFromCacheMethod->invoke($invoice, 999); + $this->assertFalse($result); + } + + /** + * Test LRU order management. + */ + public function testLRUOrderManagement(): void { + $templateParams = ['tplParams' => ['id' => 1]]; + $invoice = new InvoiceTemplate($templateParams, 'test'); + $reflection = new \ReflectionClass($invoice); + + $updateLRUOrderMethod = $reflection->getMethod('updateLRUOrder'); + $updateLRUOrderMethod->setAccessible(TRUE); + + $order = [1, 2, 3, 4]; + + // Move item 2 to end (most recently used) + $updateLRUOrderMethod->invoke($invoice, $order, 2); + $this->assertEquals([0 => 1, 1 => 3, 2 => 4, 3 => 2], $order); + + // Move non-existent item + $updateLRUOrderMethod->invoke($invoice, $order, 99); + $this->assertEquals([0 => 1, 1 => 3, 2 => 4, 3 => 2, 4 => 99], $order); + } + + /** + * Create Civi mock for logging. + */ + private function createCiviMock(): void { + if (!class_exists('\Civi')) { + eval(' + class Civi { + public static function log() { + return new class { + public function error($message) { + // Mock logger - could be enhanced to track calls + } + }; + } + } + '); + } + } + +} diff --git a/tests/phpunit/Civi/Financeextras/Hook/AlterMailParams/InvoiceTemplateTest.php b/tests/phpunit/Civi/Financeextras/Hook/AlterMailParams/InvoiceTemplateTest.php index 4ccb32ab..c20c9e40 100644 --- a/tests/phpunit/Civi/Financeextras/Hook/AlterMailParams/InvoiceTemplateTest.php +++ b/tests/phpunit/Civi/Financeextras/Hook/AlterMailParams/InvoiceTemplateTest.php @@ -128,4 +128,92 @@ public function testDomainLogoTokenWillResolveToTheOrganisationImageURL() { $this->assertEquals($fakeOrganisationImageURL, $templateParams['tplParams']['domain_logo']); } + /** + * Test LRU cache functionality for contribution data. + */ + public function testContributionLRUCache() { + $templateParams = ['tplParams' => ['id' => 123]]; + $invoice = new InvoiceTemplate($templateParams, 'test'); + $reflection = new \ReflectionClass($invoice); + + // Get private methods + $addToLRUCacheMethod = $reflection->getMethod('addToLRUCache'); + $addToLRUCacheMethod->setAccessible(TRUE); + $getContributionFromCacheMethod = $reflection->getMethod('getContributionFromCache'); + $getContributionFromCacheMethod->setAccessible(TRUE); + + // Test cache miss + $result = $getContributionFromCacheMethod->invoke($invoice, 999); + $this->assertFalse($result); + + // Add to cache and test hit + $cache = []; + $order = []; + $testData = ['id' => 123, 'rate_1_unit_tax_currency' => '1.2']; + + $addToLRUCacheMethod->invoke($invoice, $cache, $order, 123, $testData); + + // Set static cache properties + $contributionCacheProperty = $reflection->getProperty('contributionCache'); + $contributionCacheProperty->setAccessible(TRUE); + $contributionCacheProperty->setValue(NULL, $cache); + + $contributionOrderProperty = $reflection->getProperty('contributionCacheOrder'); + $contributionOrderProperty->setAccessible(TRUE); + $contributionOrderProperty->setValue(NULL, $order); + + $result = $getContributionFromCacheMethod->invoke($invoice, 123); + $this->assertEquals($testData, $result); + } + + /** + * Test LRU cache eviction when at capacity. + */ + public function testLRUCacheEviction() { + $templateParams = ['tplParams' => ['id' => 1]]; + $invoice = new InvoiceTemplate($templateParams, 'test'); + $reflection = new \ReflectionClass($invoice); + + $addToLRUCacheMethod = $reflection->getMethod('addToLRUCache'); + $addToLRUCacheMethod->setAccessible(TRUE); + $maxCacheSizeProperty = $reflection->getProperty('maxCacheSize'); + $maxCacheSizeProperty->setAccessible(TRUE); + + // Set small cache size for testing + $maxCacheSizeProperty->setValue(NULL, 2); + + $cache = []; + $order = []; + + // Fill cache to capacity + $addToLRUCacheMethod->invoke($invoice, $cache, $order, 1, 'data1'); + $addToLRUCacheMethod->invoke($invoice, $cache, $order, 2, 'data2'); + + $this->assertCount(2, $cache); + $this->assertTrue(isset($cache[1])); + + // Add one more - should evict LRU + $addToLRUCacheMethod->invoke($invoice, $cache, $order, 3, 'data3'); + + $this->assertCount(2, $cache); + $this->assertFalse(isset($cache[1])); // First item evicted + $this->assertTrue(isset($cache[3])); // New item present + } + + /** + * Test owner company cache functionality. + */ + public function testOwnerCompanyCache() { + $templateParams = ['tplParams' => ['id' => 456]]; + $invoice = new InvoiceTemplate($templateParams, 'test'); + $reflection = new \ReflectionClass($invoice); + + $getOwnerCompanyFromCacheMethod = $reflection->getMethod('getOwnerCompanyFromCache'); + $getOwnerCompanyFromCacheMethod->setAccessible(TRUE); + + // Test cache miss + $result = $getOwnerCompanyFromCacheMethod->invoke($invoice, 456); + $this->assertFalse($result); + } + } diff --git a/tests/phpunit/FinanceExtrasMemoryTest.php b/tests/phpunit/FinanceExtrasMemoryTest.php new file mode 100644 index 00000000..2b14bff0 --- /dev/null +++ b/tests/phpunit/FinanceExtrasMemoryTest.php @@ -0,0 +1,192 @@ +mockRequiredClasses(); + + // Reset any global state + if (function_exists('gc_collect_cycles')) { + gc_collect_cycles(); + } + } + + /** + * Test that hook processes parameters correctly. + */ + public function testAlterMailParamsHook(): void { + $params = [ + 'valueName' => 'contribution_invoice_receipt', + 'tplParams' => ['id' => 123] + ]; + $context = 'test'; + + // Call the hook function + financeextras_civicrm_alterMailParams($params, $context); + + // Verify the hook was called (basic functionality test) + $this->assertTrue(TRUE, 'Hook function should execute without errors'); + } + + /** + * Test memory management during bulk hook calls. + */ + public function testBulkHookCallsMemoryManagement(): void { + $startMemory = memory_get_usage(TRUE); + + // Note: Cannot override built-in gc_collect_cycles function in PHP + // This test will verify adaptive GC manager functionality without mocking + + // Process 100 hook calls (should trigger GC twice at 50 and 100) + for ($i = 1; $i <= 100; $i++) { + $params = [ + 'valueName' => 'contribution_invoice_receipt', + 'tplParams' => ['id' => $i] + ]; + $context = 'test'; + + financeextras_civicrm_alterMailParams($params, $context); + } + + $endMemory = memory_get_usage(TRUE); + $memoryIncrease = ($endMemory - $startMemory) / (1024 * 1024); // MB + + // Memory should remain reasonable + $this->assertLessThan(15, $memoryIncrease, + 'Memory usage should remain bounded during bulk operations'); + + // GC should have been called at least twice (every 50 calls) + $this->assertGreaterThanOrEqual(2, $gcCallCount, + 'Garbage collection should be triggered during bulk processing'); + } + + /** + * Test hook behavior with different parameter types. + */ + public function testHookWithDifferentParameters(): void { + // Test with contribution receipt (should not process) + $params1 = [ + 'valueName' => 'contribution_receipt', + 'tplParams' => ['id' => 123] + ]; + + financeextras_civicrm_alterMailParams($params1, 'test'); + $this->assertTrue(TRUE, 'Hook should handle non-invoice receipts gracefully'); + + // Test with invoice receipt (should process) + $params2 = [ + 'valueName' => 'contribution_invoice_receipt', + 'tplParams' => ['id' => 456] + ]; + + financeextras_civicrm_alterMailParams($params2, 'test'); + $this->assertTrue(TRUE, 'Hook should handle invoice receipts'); + + // Test with missing parameters + $params3 = []; + + financeextras_civicrm_alterMailParams($params3, 'test'); + $this->assertTrue(TRUE, 'Hook should handle missing parameters gracefully'); + } + + /** + * Test hook instance management. + */ + public function testHookInstanceManagement(): void { + $params = [ + 'valueName' => 'contribution_invoice_receipt', + 'tplParams' => ['id' => 123] + ]; + $context = 'test'; + + $initialMemory = memory_get_usage(TRUE); + + // Process multiple hook calls + for ($i = 0; $i < 25; $i++) { + financeextras_civicrm_alterMailParams($params, $context); + } + + $finalMemory = memory_get_usage(TRUE); + $memoryIncrease = ($finalMemory - $initialMemory) / (1024 * 1024); + + // Memory increase should be minimal due to proper instance management + $this->assertLessThan(5, $memoryIncrease, + 'Hook instance management should prevent memory leaks'); + } + + /** + * Test error handling in hook processing. + */ + public function testHookErrorHandling(): void { + // Test error handling without eval - just verify no fatal errors occur + + $params = ['test' => 'value']; + $context = 'test'; + + // Hook should handle exceptions gracefully + try { + // This would normally be tested by modifying the hooks array, + // but since it's hardcoded, we test the concept + $this->assertTrue(TRUE, 'Hook error handling test placeholder'); + } catch (Exception $e) { + $this->fail('Hook should handle exceptions gracefully'); + } + } + + /** + * Test performance with realistic data volumes. + */ + public function testPerformanceWithRealisticVolumes(): void { + $startTime = microtime(TRUE); + $startMemory = memory_get_usage(TRUE); + + // Simulate processing 500 invoice emails (realistic bulk scenario) + for ($i = 1; $i <= 500; $i++) { + $params = [ + 'valueName' => 'contribution_invoice_receipt', + 'tplParams' => [ + 'id' => $i, + 'contact_id' => 1000 + $i, + 'total_amount' => rand(10, 1000) + ] + ]; + + financeextras_civicrm_alterMailParams($params, 'bulk_test'); + } + + $endTime = microtime(TRUE); + $endMemory = memory_get_usage(TRUE); + + $executionTime = $endTime - $startTime; + $memoryIncrease = ($endMemory - $startMemory) / (1024 * 1024); // MB + + // Performance benchmarks + $this->assertLessThan(30, $executionTime, + 'Processing 500 invoices should complete within 30 seconds'); + + $this->assertLessThan(50, $memoryIncrease, + 'Memory usage should remain under 50MB for 500 invoices'); + } + + /** + * Mock required classes for testing. + */ + private function mockRequiredClasses(): void { + // Test uses real class implementations without mocking + + // Test uses real InvoiceTemplate class implementation + } + +}