diff --git a/ProcessMaker/Cache/Screens/LegacyScreenCacheAdapter.php b/ProcessMaker/Cache/Screens/LegacyScreenCacheAdapter.php new file mode 100644 index 0000000000..a501b5df4e --- /dev/null +++ b/ProcessMaker/Cache/Screens/LegacyScreenCacheAdapter.php @@ -0,0 +1,57 @@ +compiledManager = $compiledManager; + } + + /** + * Create a cache key for a screen + */ + public function createKey(int $processId, int $processVersionId, string $language, int $screenId, int $screenVersionId): string + { + return $this->compiledManager->createKey( + (string) $processId, + (string) $processVersionId, + $language, + (string) $screenId, + (string) $screenVersionId + ); + } + + /** + * Get a screen from cache + */ + public function get(string $key, mixed $default = null): mixed + { + $content = $this->compiledManager->getCompiledContent($key); + + return $content ?? $default; + } + + /** + * Store a screen in cache + */ + public function set(string $key, mixed $value): bool + { + $this->compiledManager->storeCompiledContent($key, $value); + + return true; + } + + /** + * Check if screen exists in cache + */ + public function has(string $key): bool + { + return $this->compiledManager->getCompiledContent($key) !== null; + } +} diff --git a/ProcessMaker/Cache/Screens/ScreenCacheFacade.php b/ProcessMaker/Cache/Screens/ScreenCacheFacade.php new file mode 100644 index 0000000000..d736353e4a --- /dev/null +++ b/ProcessMaker/Cache/Screens/ScreenCacheFacade.php @@ -0,0 +1,18 @@ +make(ScreenCompiledManager::class); + + return new LegacyScreenCacheAdapter($compiledManager); + } +} diff --git a/ProcessMaker/Cache/Screens/ScreenCacheInterface.php b/ProcessMaker/Cache/Screens/ScreenCacheInterface.php new file mode 100644 index 0000000000..db4b259b39 --- /dev/null +++ b/ProcessMaker/Cache/Screens/ScreenCacheInterface.php @@ -0,0 +1,26 @@ +cacheManager = $cacheManager; + $this->screenCompiler = $screenCompiler; + } + + /** + * Create a cache key for a screen + * + * @param int $processId Process ID + * @param int $processVersionId Process version ID + * @param string $language Language code + * @param int $screenId Screen ID + * @param int $screenVersionId Screen version ID + * @return string Cache key + */ + public function createKey(int $processId, int $processVersionId, string $language, int $screenId, int $screenVersionId): string + { + return "pid_{$processId}_{$processVersionId}_{$language}_sid_{$screenId}_{$screenVersionId}"; + } + + /** + * Get a screen from cache + * + * @param string $key Screen cache key + * @param mixed $default Default value + * @return mixed + */ + public function get(string $key, mixed $default = null): mixed + { + $serializedContent = $this->cacheManager->get($key); + if ($serializedContent !== null) { + return unserialize($serializedContent); + } + + return $default; + } + + /** + * Store a screen in memory cache + * + * @param string $key Screen cache key + * @param mixed $value Compiled screen content + * @param null|int|\DateInterval $ttl Time to live + * @return bool + */ + public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + { + $serializedContent = serialize($value); + + return $this->cacheManager->put($key, $serializedContent, $ttl ?? self::DEFAULT_TTL); + } + + /** + * Delete a screen from cache + * + * @param string $key Screen cache key + * @return bool + */ + public function delete(string $key): bool + { + return $this->cacheManager->forget($key); + } + + /** + * Clear all screen caches + * + * @return bool + */ + public function clear(): bool + { + return $this->cacheManager->flush(); + } + + /** + * Check if screen exists in cache + * + * @param string $key Screen cache key + * @return bool + */ + public function has(string $key): bool + { + return $this->cacheManager->has($key); + } + + /** + * Check if screen is missing + * + * @param string $key Screen cache key + * @return bool + */ + public function missing(string $key): bool + { + return !$this->has($key); + } +} diff --git a/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php b/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php index 41dab2cf46..46ce3f13b8 100644 --- a/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php @@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use ProcessMaker\Facades\ScreenCompiledManager; +use ProcessMaker\Cache\Screens\ScreenCacheFactory; use ProcessMaker\Http\Controllers\Controller; use ProcessMaker\Http\Resources\V1_1\TaskInterstitialResource; use ProcessMaker\Http\Resources\V1_1\TaskResource; @@ -102,31 +102,43 @@ public function showScreen($taskId) $task = ProcessRequestToken::select( array_merge($this->defaultFields, ['process_request_id', 'process_id']) ) - // eager loading process_request.process_version_id ->with([ - 'processRequest'=> function ($query) { + 'processRequest' => function ($query) { $query->select('id', 'process_version_id'); }, ])->findOrFail($taskId); - // Prepare the key for the screen cache + // Get screen version and prepare cache key $processId = $task->process_id; $processVersionId = $task->processRequest->process_version_id; $language = TranslationManager::getTargetLanguage(); $screenVersion = $task->getScreenVersion(); - $key = ScreenCompiledManager::createKey($processId, $processVersionId, $language, $screenVersion->screen_id, $screenVersion->id); - // Get the screen content from the cache or compile it - $translatedScreen = ScreenCompiledManager::getCompiledContent($key); - if (!isset($translatedScreen)) { + // Get the appropriate cache handler based on configuration + $screenCache = ScreenCacheFactory::create(); + + // Create cache key + $key = $screenCache->createKey( + (int) $processId, + (int) $processVersionId, + $language, + (int) $screenVersion->screen_id, + (int) $screenVersion->id + ); + + // Try to get the screen from cache + $translatedScreen = $screenCache->get($key); + + if ($translatedScreen === null) { + // If not in cache, create new response $response = new TaskScreen($task); $translatedScreen = $response->toArray(request())['screen']; - ScreenCompiledManager::storeCompiledContent($key, $translatedScreen); - } - $response = response($translatedScreen, 200); + // Store in cache + $screenCache->set($key, $translatedScreen); + } - return $response; + return response($translatedScreen, 200); } public function showInterstitial($taskId) diff --git a/config/screens.php b/config/screens.php new file mode 100644 index 0000000000..4635f58c3c --- /dev/null +++ b/config/screens.php @@ -0,0 +1,23 @@ + [ + // Cache manager to use: 'new' for ScreenCacheManager, 'legacy' for ScreenCompiledManager + 'manager' => env('SCREEN_CACHE_MANAGER', 'legacy'), + + // Cache driver to use (redis, file) + 'driver' => env('SCREEN_CACHE_DRIVER', 'file'), + + // Default TTL for cached screens (24 hours) + 'ttl' => env('SCREEN_CACHE_TTL', 86400), + ], +]; diff --git a/tests/Feature/Api/V1_1/TaskControllerTest.php b/tests/Feature/Api/V1_1/TaskControllerTest.php index 67f87100d2..e93c3836ce 100644 --- a/tests/Feature/Api/V1_1/TaskControllerTest.php +++ b/tests/Feature/Api/V1_1/TaskControllerTest.php @@ -3,12 +3,16 @@ namespace Tests\Feature\Api\V1_1; use Illuminate\Support\Facades\Auth; +use ProcessMaker\Cache\Screens\ScreenCacheFactory; +use ProcessMaker\Cache\Screens\ScreenCacheManager; use ProcessMaker\Facades\ScreenCompiledManager; use ProcessMaker\Http\Resources\V1_1\TaskScreen; use ProcessMaker\Jobs\ImportProcess; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; +use ProcessMaker\Models\Screen; +use ProcessMaker\Models\ScreenVersion; use Tests\Feature\Shared\RequestHelper; use Tests\TestCase; @@ -16,7 +20,11 @@ class TaskControllerTest extends TestCase { use RequestHelper; - protected $taskController; + protected function tearDown(): void + { + parent::tearDown(); + ScreenCacheFactory::setTestInstance(null); + } public function testShow() { @@ -51,14 +59,17 @@ public function testShowScreen() public function testShowScreenCache() { + // Create test data $content = file_get_contents( __DIR__ . '/Fixtures/nested_screen_process.json' ); ImportProcess::dispatchSync($content); + + $process = Process::where('name', 'nested screen test')->first(); $request = ProcessRequest::factory()->create([ - 'process_id' => Process::where('name', 'nested screen test')->first()->id, + 'process_id' => $process->id, ]); - $process = Process::where('name', 'nested screen test')->first(); + $processVersion = $process->getPublishedVersion([]); $task = ProcessRequestToken::factory()->create([ 'element_type' => 'task', @@ -67,55 +78,71 @@ public function testShowScreenCache() 'process_id' => $process->id, 'process_request_id' => $request->id, ]); + $screenVersion = $task->getScreenVersion(); + $this->assertNotNull($screenVersion, 'Screen version not found'); - // Prepare the key for the screen cache + // Set up test user Auth::setUser($this->user); + + // Create cache manager mock + $screenCache = $this->getMockBuilder(ScreenCacheManager::class) + ->disableOriginalConstructor() + ->getMock(); + + // Set up ScreenCacheFactory to return our mock + ScreenCacheFactory::setTestInstance($screenCache); + + // Set up expected cache key parameters $processId = $process->id; $processVersionId = $processVersion->id; $language = $this->user->language; $screenId = $screenVersion->screen_id; $screenVersionId = $screenVersion->id; + $screenKey = "pid_{$processId}_{$processVersionId}_{$language}_sid_{$screenId}_{$screenVersionId}"; + + // Mock createKey method + $screenCache->expects($this->once()) + ->method('createKey') + ->with( + $processId, + $processVersionId, + $language, + $screenId, + $screenVersionId + ) + ->willReturn($screenKey); + + // Mock cached content + $cachedContent = [ + 'id' => $screenId, + 'screen_version_id' => $screenVersionId, + 'config' => ['some' => 'config'], + 'watchers' => [], + 'computed' => [], + 'type' => 'FORM', + 'title' => 'Test Screen', + 'description' => '', + 'screen_category_id' => null, + 'nested' => [], + ]; + + $screenCache->expects($this->once()) + ->method('get') + ->with($screenKey) + ->willReturn($cachedContent); - // Get the screen cache key - $screenKey = ScreenCompiledManager::createKey( - $processId, - $processVersionId, - $language, - $screenId, - $screenVersionId + // Make the API call + $response = $this->apiCall( + 'GET', + route('api.1.1.tasks.show.screen', $task->id) . '?include=screen,nested' ); - // Prepare the screen content with nested to be stored in the cache - $response = new TaskScreen($task); - $request = new \Illuminate\Http\Request(); - $request->setUserResolver(function () { - return $this->user; - }); - // add query param include=screen,nested - $request->query->add(['include' => 'screen,nested']); - $content = $response->toArray($request)['screen']; - - // Mock the ScreenCompiledManager - ScreenCompiledManager::shouldReceive('createKey') - ->once() - ->withAnyArgs() - ->andReturn($screenKey); - ScreenCompiledManager::shouldReceive('getCompiledContent') - ->once() - ->with($screenKey) - ->andReturn(null); - ScreenCompiledManager::shouldReceive('storeCompiledContent') - ->once() - ->withAnyArgs($screenKey, $content) - ->andReturn(null); - - // Assert the expected screen content is returned - $response = $this->apiCall('GET', route('api.1.1.tasks.show.screen', $task->id) . '?include=screen,nested'); + // Assertions $this->assertNotNull($response->json()); $this->assertIsArray($response->json()); $response->assertStatus(200); - $response->assertJson($content); + $response->assertJson($cachedContent); } public function testIncludeSubprocessTasks() diff --git a/tests/unit/ProcessMaker/Cache/Screens/LegacyScreenCacheAdapterTest.php b/tests/unit/ProcessMaker/Cache/Screens/LegacyScreenCacheAdapterTest.php new file mode 100644 index 0000000000..52550b8da1 --- /dev/null +++ b/tests/unit/ProcessMaker/Cache/Screens/LegacyScreenCacheAdapterTest.php @@ -0,0 +1,120 @@ +compiledManager = Mockery::mock(ScreenCompiledManager::class); + $this->adapter = new LegacyScreenCacheAdapter($this->compiledManager); + } + + /** @test */ + public function it_creates_correct_cache_key() + { + $this->compiledManager->shouldReceive('createKey') + ->once() + ->with('1', '2', 'en', '3', '4') + ->andReturn('pid_1_2_en_sid_3_4'); + + $key = $this->adapter->createKey(1, 2, 'en', 3, 4); + + $this->assertEquals('pid_1_2_en_sid_3_4', $key); + } + + /** @test */ + public function it_gets_content_from_compiled_manager() + { + $key = 'test_key'; + $expectedValue = ['content' => 'test']; + + $this->compiledManager->shouldReceive('getCompiledContent') + ->once() + ->with($key) + ->andReturn($expectedValue); + + $result = $this->adapter->get($key); + + $this->assertEquals($expectedValue, $result); + } + + /** @test */ + public function it_returns_default_value_when_content_missing() + { + $key = 'missing_key'; + $default = ['default' => 'value']; + + $this->compiledManager->shouldReceive('getCompiledContent') + ->once() + ->with($key) + ->andReturnNull(); + + $result = $this->adapter->get($key, $default); + + $this->assertEquals($default, $result); + } + + /** @test */ + public function it_stores_content_in_compiled_manager() + { + $key = 'test_key'; + $value = ['content' => 'test']; + + $this->compiledManager->shouldReceive('storeCompiledContent') + ->once() + ->with($key, $value) + ->andReturnNull(); + + $result = $this->adapter->set($key, $value); + + $this->assertTrue($result); + } + + /** @test */ + public function it_checks_existence_in_compiled_manager() + { + $key = 'test_key'; + + $this->compiledManager->shouldReceive('getCompiledContent') + ->once() + ->with($key) + ->andReturn(['content' => 'exists']); + + $result = $this->adapter->has($key); + + $this->assertTrue($result); + } + + /** @test */ + public function it_returns_false_when_checking_missing_content() + { + $key = 'missing_key'; + + $this->compiledManager->shouldReceive('getCompiledContent') + ->once() + ->with($key) + ->andReturnNull(); + + $result = $this->adapter->has($key); + + $this->assertFalse($result); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheFactoryTest.php b/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheFactoryTest.php new file mode 100644 index 0000000000..97383b89f7 --- /dev/null +++ b/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheFactoryTest.php @@ -0,0 +1,43 @@ +assertInstanceOf(ScreenCacheManager::class, $cacheHandler); + } + + /** @test */ + public function it_creates_legacy_adapter_by_default() + { + Config::set('screens.cache.manager', 'legacy'); + + $cacheHandler = ScreenCacheFactory::create(); + + $this->assertInstanceOf(LegacyScreenCacheAdapter::class, $cacheHandler); + } + + /** @test */ + public function it_creates_legacy_adapter_when_config_missing() + { + Config::set('screens.cache.manager', null); + + $cacheHandler = ScreenCacheFactory::create(); + + $this->assertInstanceOf(LegacyScreenCacheAdapter::class, $cacheHandler); + } +} diff --git a/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheManagerTest.php b/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheManagerTest.php new file mode 100644 index 0000000000..ff92b53dc1 --- /dev/null +++ b/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheManagerTest.php @@ -0,0 +1,280 @@ +cacheManager = Mockery::mock(CacheManager::class); + $this->screenCompiler = Mockery::mock(ScreenCompiledManager::class); + + // Create instance with mocked dependencies + $this->screenCache = new ScreenCacheManager( + $this->cacheManager, + $this->screenCompiler + ); + + // Clear Redis before each test + Redis::flushdb(); + } + + /** @test */ + public function testCreatesCorrectCacheKey() + { + $languages = ['en', 'es', 'fr', 'de']; + + foreach ($languages as $lang) { + $key = $this->screenCache->createKey(1, 2, $lang, 3, 4); + $expectedKey = "pid_1_2_{$lang}_sid_3_4"; + + $this->assertEquals($expectedKey, $key); + } + } + + /** @test */ + public function testStoresAndRetrievesFromMemoryCache() + { + $key = 'test_screen'; + $value = ['content' => 'test']; + + // Set up expectations + $this->cacheManager->shouldReceive('put') + ->once() + ->with($key, serialize($value), 86400) + ->andReturn(true); + + $this->cacheManager->shouldReceive('get') + ->once() + ->with($key) + ->andReturn(serialize($value)); + + // Execute and verify + $this->screenCache->set($key, $value); + $result = $this->screenCache->get($key); + + $this->assertEquals($value, $result); + } + + /** @test */ + public function testHandlesTranslations() + { + $key = 'test_screen'; + $value = ['content' => 'test', 'title' => 'Original Title']; + $serializedValue = serialize($value); + + // Set up expectations for initial store + $this->cacheManager->shouldReceive('put') + ->once() + ->with($key, $serializedValue, 86400) + ->andReturn(true); + + // Set up expectations for retrieval + $this->cacheManager->shouldReceive('get') + ->once() + ->with($key) + ->andReturn($serializedValue); + + // Store and retrieve with translation + $this->screenCache->set($key, $value); + $result = $this->screenCache->get($key); + + $this->assertEquals($value, $result); + $this->assertEquals('Original Title', $result['title']); + } + + /** @test */ + public function testHandlesNestedScreens() + { + $key = 'test_screen'; + $nestedKey = 'nested_screen'; + + $nestedContent = ['content' => 'nested content']; + $parentContent = [ + 'component' => 'FormScreen', + 'config' => [ + 'screenId' => 123, + 'content' => $nestedContent, + ], + ]; + + // Set up expectations for nested screen + $this->cacheManager->shouldReceive('get') + ->once() + ->with($nestedKey) + ->andReturn(serialize($nestedContent)); + + $this->cacheManager->shouldReceive('put') + ->once() + ->with($key, serialize($parentContent), 86400) + ->andReturn(true); + + $this->cacheManager->shouldReceive('get') + ->once() + ->with($key) + ->andReturn(serialize($parentContent)); + + // Store and retrieve parent screen + $this->screenCache->set($key, $parentContent); + $result = $this->screenCache->get($key); + $this->screenCache->get($nestedKey); + + // Verify parent and nested content + $this->assertEquals($parentContent, $result); + $this->assertEquals($nestedContent, $result['config']['content']); + } + + /** @test */ + public function testTracksCacheStatistics() + { + $key = 'test_stats'; + $value = ['data' => 'test']; + $serializedValue = serialize($value); + + // Initialize Redis counters + Redis::set('screen_cache:stats:hits', 0); + Redis::set('screen_cache:stats:misses', 0); + Redis::set('screen_cache:stats:size', 0); + + // Test cache hit + $this->cacheManager->shouldReceive('get') + ->once() + ->with($key) + ->andReturn($serializedValue); + + $this->screenCache->get($key); + Redis::incr('screen_cache:stats:hits'); + $this->assertEquals(1, Redis::get('screen_cache:stats:hits')); + + // Test cache miss + $this->cacheManager->shouldReceive('get') + ->once() + ->with('missing_key') + ->andReturnNull(); + + $this->screenCache->get('missing_key'); + Redis::incr('screen_cache:stats:misses'); + $this->assertEquals(1, Redis::get('screen_cache:stats:misses')); + + // Test cache size tracking + $this->cacheManager->shouldReceive('put') + ->once() + ->with($key, $serializedValue, 86400) + ->andReturn(true); + + $this->screenCache->set($key, $value); + Redis::incrBy('screen_cache:stats:size', strlen($serializedValue)); + $this->assertGreaterThan(0, Redis::get('screen_cache:stats:size')); + } + + /** @test */ + public function testDeletesFromCache() + { + $key = 'test_delete'; + + // Set up expectations + $this->cacheManager->shouldReceive('forget') + ->once() + ->with($key) + ->andReturn(true); + + // Execute delete and verify return value + $result = $this->screenCache->delete($key); + $this->assertTrue($result); + + // Verify forget was called + $this->cacheManager->shouldHaveReceived('forget') + ->once() + ->with($key); + } + + /** @test */ + public function testClearsEntireCache() + { + // Set up expectations + $this->cacheManager->shouldReceive('flush') + ->once() + ->andReturn(true); + + $result = $this->screenCache->clear(); + + // Verify the clear operation was successful + $this->assertTrue($result); + + // Verify flush was called + $this->cacheManager->shouldHaveReceived('flush') + ->once(); + } + + /** @test */ + public function testChecksIfKeyExists() + { + $key = 'test_exists'; + + // Test when key exists + $this->cacheManager->shouldReceive('has') + ->once() + ->with($key) + ->andReturn(true); + + $this->assertTrue($this->screenCache->has($key)); + + // Test when key doesn't exist + $this->cacheManager->shouldReceive('has') + ->once() + ->with($key) + ->andReturn(false); + + $this->assertFalse($this->screenCache->has($key)); + } + + /** @test */ + public function testChecksIfKeyIsMissing() + { + $key = 'test_missing'; + + // Test when key exists + $this->cacheManager->shouldReceive('has') + ->once() + ->with($key) + ->andReturn(true); + + $this->assertFalse($this->screenCache->missing($key)); + + // Test when key doesn't exist + $this->cacheManager->shouldReceive('has') + ->once() + ->with($key) + ->andReturn(false); + + $this->assertTrue($this->screenCache->missing($key)); + } + + protected function tearDown(): void + { + Mockery::close(); + Redis::flushdb(); + parent::tearDown(); + } +}