diff --git a/.env.example b/.env.example index 1b2468d570..3b710f8c94 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,5 @@ OPEN_AI_SECRET="sk-O2D..." AI_MICROSERVICE_HOST="http://localhost:8010" PROCESS_REQUEST_ERRORS_RATE_LIMIT=1 PROCESS_REQUEST_ERRORS_RATE_LIMIT_DURATION=86400 +CACHE_SETTING_DRIVER=cache_settings +CACHE_SETTING_PREFIX=settings diff --git a/ProcessMaker/Cache/AbstractCacheFactory.php b/ProcessMaker/Cache/AbstractCacheFactory.php new file mode 100644 index 0000000000..7ca46776ee --- /dev/null +++ b/ProcessMaker/Cache/AbstractCacheFactory.php @@ -0,0 +1,61 @@ +make(PrometheusMetricsManager::class)); + } + + /** + * Create the specific cache instance + * + * @param CacheManager $cacheManager + * @return CacheInterface + */ + abstract protected static function createInstance(CacheManager $cacheManager): CacheInterface; +} diff --git a/ProcessMaker/Cache/CacheFactoryInterface.php b/ProcessMaker/Cache/CacheFactoryInterface.php new file mode 100644 index 0000000000..5d910024a8 --- /dev/null +++ b/ProcessMaker/Cache/CacheFactoryInterface.php @@ -0,0 +1,18 @@ +keys($prefix . '*'); + + // Filter keys by pattern + return array_filter($keys, fn ($key) => preg_match('/' . $pattern . '/', $key)); + } catch (Exception $e) { + Log::info('CacheManagerBase: ' . $e->getMessage()); + } + + return []; + } +} diff --git a/ProcessMaker/Cache/CacheManagerException.php b/ProcessMaker/Cache/CacheManagerException.php new file mode 100644 index 0000000000..e0352c28f7 --- /dev/null +++ b/ProcessMaker/Cache/CacheManagerException.php @@ -0,0 +1,9 @@ +cache = $cache; + $this->metrics = $metrics; + } + + /** + * Create a cache key based on provided parameters + * + * @param array $params Key parameters + * @return string Generated cache key + */ + public function createKey(array $params): string + { + if ($this->cache instanceof CacheInterface) { + return $this->cache->createKey($params); + } + + throw new \RuntimeException('Underlying cache implementation does not support createKey method'); + } + + /** + * Get a value from the cache + * + * Records timing and hit/miss metrics for the get operation + * + * @param string $key Cache key + * @param mixed $default Default value if key not found + * @return mixed Cached value or default + */ + public function get(string $key, mixed $default = null): mixed + { + $startTime = microtime(true); + + // First check if the key exists + $exists = $this->cache->has($key); + + // Get the value + $value = $this->cache->get($key, $default); + + $endTime = microtime(true); + $duration = $endTime - $startTime; + + // Get extra labels for metrics + $labels = []; + if ($value instanceof PrometheusMetricInterface) { + $labels['label'] = $value->getPrometheusMetricLabel(); + } else { + $labels['label'] = $key; + } + // Record metrics based on key existence, not value comparison + if ($exists) { + $this->metrics->recordHit($key, $duration, $labels); + } else { + $this->metrics->recordMiss($key, $duration, $labels); + } + + return $value; + } + + /** + * Store a value in the cache + * + * Records metrics about the size of stored data + * + * @param string $key Cache key + * @param mixed $value Value to cache + * @param null|int|\DateInterval $ttl Optional TTL + * @return bool True if successful + */ + public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + { + $result = $this->cache->set($key, $value, $ttl); + + // Get extra labels for metrics + $labels = []; + if ($value instanceof PrometheusMetricInterface) { + $labels['label'] = $value->getPrometheusMetricLabel(); + } else { + $labels['label'] = $key; + } + + if ($result) { + // Calculate approximate size in bytes + $size = $this->calculateSize($value); + $this->metrics->recordWrite($key, $size, $labels); + } + + return $result; + } + + /** + * Delete a value from the cache + * + * @param string $key Cache key + * @return bool True if successful + */ + public function delete(string $key): bool + { + return $this->cache->delete($key); + } + + /** + * Clear all values from the cache + * + * @return bool True if successful + */ + public function clear(): bool + { + return $this->cache->clear(); + } + + /** + * Check if a key exists in the cache + * + * @param string $key Cache key + * @return bool True if key exists + */ + public function has(string $key): bool + { + return $this->cache->has($key); + } + + /** + * Check if a key is missing from the cache + * + * @param string $key Cache key + * @return bool True if key is missing + */ + public function missing(string $key): bool + { + return $this->cache->missing($key); + } + + /** + * Invalidate cache for a specific screen + * + * @param int $screenId Screen ID + * @return bool + * @throws \RuntimeException If underlying cache doesn't support invalidate + */ + public function invalidate($params): void + { + if (!$this->cache instanceof CacheInterface) { + throw new \RuntimeException('Underlying cache implementation does not support invalidate method'); + } + + $this->cache->invalidate($params); + } + + /** + * Calculate the approximate size in bytes of a value + * + * @param mixed $value Value to calculate size for + * @return int Size in bytes + */ + protected function calculateSize(mixed $value): int + { + if (is_string($value)) { + return strlen($value); + } + + if (is_array($value) || is_object($value)) { + return strlen(serialize($value)); + } + + if (is_int($value)) { + return PHP_INT_SIZE; + } + + if (is_float($value)) { + return 8; // typical double size + } + + if (is_bool($value)) { + return 1; + } + + return 0; // for null or other types + } + + /** + * Get a value from the cache or store it if it doesn't exist. + * + * @param string $key + * @param callable $callback + * @return mixed + */ + public function getOrCache(string $key, callable $callback): mixed + { + if ($this->cache->has($key)) { + return $this->cache->get($key); + } + + $value = $callback(); + $this->cache->set($key, $value); + + return $value; + } + + /** + * Clear compiled assets from cache and record metrics + * + * This method clears compiled assets from the cache and records the operation + * as a write with size 0 since we are removing content rather than adding it. + * The execution time is measured but not currently used. + */ + public function clearCompiledAssets(): void + { + $startTime = microtime(true); + $this->cache->clearCompiledAssets(); + $timeTaken = microtime(true) - $startTime; + } +} diff --git a/ProcessMaker/Cache/Monitoring/CacheMetricsInterface.php b/ProcessMaker/Cache/Monitoring/CacheMetricsInterface.php new file mode 100644 index 0000000000..030e8162ea --- /dev/null +++ b/ProcessMaker/Cache/Monitoring/CacheMetricsInterface.php @@ -0,0 +1,34 @@ +metrics = Metrics::getFacadeRoot(); + $this->namespace = $namespace; + } + + /** + * Record a cache hit + * + * @param string $key Cache key + * @param float $microtime Time taken in microseconds + */ + public function recordHit(string $key, $microtime, array $labels = []): void + { + $sanitizedKey = $this->sanitizeKey($key); + $labelKeys = array_keys($labels); + + $this->metrics->counter( + 'cache_hits_total', + 'Total number of cache hits', + ['cache_key', ...$labelKeys] + )->inc(['cache_key' => $sanitizedKey, ...$labels]); + // record the last write timestamp + $this->metrics->gauge( + 'cache_last_write_timestamp', + 'Last write timestamp', + ['cache_key', ...$labelKeys] + )->set($microtime, ['cache_key' => $sanitizedKey, ...$labels]); + } + + /** + * Record a cache miss + * + * @param string $key Cache key + * @param float $microtime Time taken in microseconds + */ + public function recordMiss(string $key, $microtime, array $labels = []): void + { + $sanitizedKey = $this->sanitizeKey($key); + $labelKeys = array_keys($labels); + + $this->metrics->counter( + 'cache_misses_total', + 'Total number of cache misses', + ['cache_key', ...$labelKeys] + )->inc(['cache_key' => $sanitizedKey, ...$labels]); + + // record the last write timestamp + $this->metrics->gauge( + 'cache_last_write_timestamp', + 'Last write timestamp', + ['cache_key', ...$labelKeys] + )->set($microtime, ['cache_key' => $sanitizedKey, ...$labels]); + } + + /** + * Record a cache write operation + * + * @param string $key Cache key + * @param int $size Size in bytes + */ + public function recordWrite(string $key, int $size, array $labels = []): void + { + $sanitizedKey = $this->sanitizeKey($key); + $labelKeys = array_keys($labels); + + $this->metrics->gauge( + 'cache_memory_bytes', + 'Memory usage in bytes', + ['cache_key', ...$labelKeys] + )->set($size, ['cache_key' => $sanitizedKey, ...$labels]); + } + + /** + * Sanitize a cache key to be used as a Prometheus label + * + * @param string $key Cache key + * @return string Sanitized cache key + */ + protected function sanitizeKey(string $key): string + { + return str_replace([':', '/', ' '], '_', $key); + } +} diff --git a/ProcessMaker/Cache/Monitoring/RedisMetricsManager.php b/ProcessMaker/Cache/Monitoring/RedisMetricsManager.php new file mode 100644 index 0000000000..343f0bf0ae --- /dev/null +++ b/ProcessMaker/Cache/Monitoring/RedisMetricsManager.php @@ -0,0 +1,268 @@ +hincrby($baseKey, self::HITS_KEY, 1); + $pipe->rpush($baseKey . ':' . self::HIT_TIMES_KEY, $microtime); + $pipe->ltrim($baseKey . ':' . self::HIT_TIMES_KEY, -100, -1); + }); + } + + /** + * Record a cache miss + * + * @param string $key Cache key + * @param float $microtime Time taken in microseconds + */ + public function recordMiss(string $key, $microtime, array $labels = []): void + { + $baseKey = self::METRICS_PREFIX . $key; + Redis::pipeline(function ($pipe) use ($baseKey, $microtime) { + $pipe->hincrby($baseKey, self::MISSES_KEY, 1); + $pipe->rpush($baseKey . ':' . self::MISS_TIMES_KEY, $microtime); + $pipe->ltrim($baseKey . ':' . self::MISS_TIMES_KEY, -100, -1); + }); + } + + /** + * Record a cache write operation + * + * @param string $key Cache key + * @param int $size Size in bytes + */ + public function recordWrite(string $key, int $size, array $labels = []): void + { + $baseKey = self::METRICS_PREFIX . $key; + Redis::pipeline(function ($pipe) use ($baseKey, $size) { + $pipe->hset($baseKey, self::MEMORY_KEY, $size); + $pipe->hset($baseKey, self::LAST_WRITE_KEY, microtime(true)); + }); + } + + /** + * Get hit rate for a specific key + * + * @param string $key Cache key + * @return float Hit rate percentage (0-1) + */ + public function getHitRate(string $key): float + { + $baseKey = self::METRICS_PREFIX . $key; + $hits = (int) Redis::hget($baseKey, self::HITS_KEY) ?: 0; + $misses = (int) Redis::hget($baseKey, self::MISSES_KEY) ?: 0; + $total = $hits + $misses; + + return $total > 0 ? $hits / $total : 0; + } + + /** + * Get miss rate for a specific key + * + * @param string $key Cache key + * @return float Miss rate percentage (0-1) + */ + public function getMissRate(string $key): float + { + $baseKey = self::METRICS_PREFIX . $key; + $hits = (int) Redis::hget($baseKey, self::HITS_KEY) ?: 0; + $misses = (int) Redis::hget($baseKey, self::MISSES_KEY) ?: 0; + $total = $hits + $misses; + + return $total > 0 ? $misses / $total : 0; + } + + /** + * Get average hit time for a specific key + * + * @param string $key Cache key + * @return float Average hit time in seconds + */ + public function getHitAvgTime(string $key): float + { + $times = Redis::lrange(self::METRICS_PREFIX . $key . ':' . self::HIT_TIMES_KEY, 0, -1); + if (empty($times)) { + return 0; + } + + return array_sum(array_map('floatval', $times)) / count($times); + } + + /** + * Get average miss time for a specific key + * + * @param string $key Cache key + * @return float Average miss time in seconds + */ + public function getMissAvgTime(string $key): float + { + $times = Redis::lrange(self::METRICS_PREFIX . $key . ':' . self::MISS_TIMES_KEY, 0, -1); + if (empty($times)) { + return 0; + } + + return array_sum(array_map('floatval', $times)) / count($times); + } + + /** + * Get top accessed keys + * + * @param int $count Number of keys to return + * @return array Top keys with their metrics + */ + public function getTopKeys(int $count = 5): array + { + $keys = Redis::keys(self::METRICS_PREFIX . '*'); + $metrics = []; + + foreach ($keys as $redisKey) { + if (str_contains($redisKey, ':' . self::HIT_TIMES_KEY) || + str_contains($redisKey, ':' . self::MISS_TIMES_KEY)) { + continue; + } + + $key = str_replace(self::METRICS_PREFIX, '', $redisKey); + $hits = (int) Redis::hget($redisKey, self::HITS_KEY) ?: 0; + $misses = (int) Redis::hget($redisKey, self::MISSES_KEY) ?: 0; + $total = $hits + $misses; + + if ($total > 0) { + $metrics[$key] = [ + 'key' => $key, + 'hits' => $hits, + 'misses' => $misses, + 'total_accesses' => $total, + 'hit_ratio' => $hits / $total, + 'miss_ratio' => $misses / $total, + 'avg_hit_time' => $this->getHitAvgTime($key), + 'avg_miss_time' => $this->getMissAvgTime($key), + 'memory_usage' => $this->getMemoryUsage($key)['current_size'], + 'last_write' => Redis::hget($redisKey, self::LAST_WRITE_KEY), + ]; + } + } + + uasort($metrics, fn ($a, $b) => $b['total_accesses'] <=> $a['total_accesses']); + + return array_slice($metrics, 0, $count, true); + } + + /** + * Get memory usage for a specific key + * + * @param string $key Cache key + * @return array Memory usage statistics + */ + public function getMemoryUsage(string $key): array + { + $baseKey = self::METRICS_PREFIX . $key; + $currentSize = (int) Redis::hget($baseKey, self::MEMORY_KEY) ?: 0; + $lastWrite = Redis::hget($baseKey, self::LAST_WRITE_KEY); + + return [ + 'current_size' => $currentSize, + 'last_write' => $lastWrite ? (float) $lastWrite : null, + ]; + } + + /** + * Reset all metrics + */ + public function resetMetrics(): void + { + $keys = Redis::keys(self::METRICS_PREFIX . '*'); + if (!empty($keys)) { + Redis::del(...$keys); + } + } + + /** + * Get summary of all metrics + * + * @return array Summary statistics + */ + public function getSummary(): array + { + $keys = Redis::keys(self::METRICS_PREFIX . '*'); + $metrics = []; + $totalHits = 0; + $totalMisses = 0; + $totalMemory = 0; + $totalHitTime = 0; + $totalMissTime = 0; + $keyCount = 0; + + foreach ($keys as $redisKey) { + if (str_contains($redisKey, ':' . self::HIT_TIMES_KEY) || + str_contains($redisKey, ':' . self::MISS_TIMES_KEY)) { + continue; + } + + $key = str_replace(self::METRICS_PREFIX, '', $redisKey); + $hits = (int) Redis::hget($redisKey, self::HITS_KEY) ?: 0; + $misses = (int) Redis::hget($redisKey, self::MISSES_KEY) ?: 0; + $memory = (int) Redis::hget($redisKey, self::MEMORY_KEY) ?: 0; + + $totalHits += $hits; + $totalMisses += $misses; + $totalMemory += $memory; + $totalHitTime += $this->getHitAvgTime($key); + $totalMissTime += $this->getMissAvgTime($key); + + // Total represents the total number of cache access attempts (hits + misses) + // We need this sum to calculate hit_ratio and miss_ratio percentages + // Example: If hits=8 and misses=2, total=10, so hit_ratio=8/10=0.8 (80%) and miss_ratio=2/10=0.2 (20%) + $total = $hits + $misses; + $metrics[$key] = [ + 'hits' => $hits, + 'misses' => $misses, + 'hit_ratio' => $total > 0 ? $hits / $total : 0, + 'miss_ratio' => $total > 0 ? $misses / $total : 0, + 'avg_hit_time' => $this->getHitAvgTime($key), + 'avg_miss_time' => $this->getMissAvgTime($key), + 'memory_usage' => $memory, + ]; + + $keyCount++; + } + + $total = $totalHits + $totalMisses; + + return [ + 'keys' => $metrics, + 'overall_hit_ratio' => $total > 0 ? $totalHits / $total : 0, + 'overall_miss_ratio' => $total > 0 ? $totalMisses / $total : 0, + 'avg_hit_time' => $keyCount > 0 ? $totalHitTime / $keyCount : 0, + 'avg_miss_time' => $keyCount > 0 ? $totalMissTime / $keyCount : 0, + 'total_memory_usage' => $totalMemory, + 'total_keys' => $keyCount, + ]; + } +} diff --git a/ProcessMaker/Cache/Screens/LegacyScreenCacheAdapter.php b/ProcessMaker/Cache/Screens/LegacyScreenCacheAdapter.php new file mode 100644 index 0000000000..9f413c34fe --- /dev/null +++ b/ProcessMaker/Cache/Screens/LegacyScreenCacheAdapter.php @@ -0,0 +1,141 @@ +compiledManager = $compiledManager; + } + + /** + * Create a cache key for a screen + */ + public function createKey(array $params): string + { + // Validate required parameters + if (!isset($params['process_id'], $params['process_version_id'], $params['language'], + $params['screen_id'], $params['screen_version_id'])) { + throw new \InvalidArgumentException('Missing required parameters for screen cache key'); + } + + return $this->compiledManager->createKey( + (string) $params['process_id'], + (string) $params['process_version_id'], + $params['language'], + (string) $params['screen_id'], + (string) $params['screen_version_id'] + ); + } + + /** + * Get a screen from cache + */ + public function get(string $key, mixed $default = null): mixed + { + $content = $this->compiledManager->getCompiledContent($key); + + return $content ?? $default; + } + + /** + * Get a value from the cache, or store the value from the callback if it doesn't exist + * + * @param string $key The key to look up + * @param callable $callback The callback that will return the value to store + * @return mixed The value from cache or the callback + * @throws \InvalidArgumentException + */ + public function getOrCache(string $key, callable $callback): mixed + { + $value = $this->get($key); + + if ($value !== null) { + return $value; + } + + $value = $callback(); + $this->set($key, $value); + + return $value; + } + + /** + * Store a screen in cache + * + * @param string $key The key of the item to store + * @param mixed $value The value of the item to store + * @param DateInterval|int|null $ttl Optional TTL value + * @return bool True on success and false on failure + */ + public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool + { + // Note: The legacy compiled manager doesn't support TTL, + // so we ignore the TTL parameter for backward compatibility + $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; + } + + /** + * Delete a screen from cache + */ + public function delete(string $key): bool + { + return $this->compiledManager->deleteCompiledContent($key); + } + + /** + * Clear all screen caches + */ + public function clear(): bool + { + return $this->compiledManager->clearCompiledContent(); + } + + /** + * Check if screen is missing from cache + */ + public function missing(string $key): bool + { + return !$this->has($key); + } + + /** + * Invalidate all cache entries for a specific screen + * + * @param int $screenId Screen ID + * @return bool + */ + public function invalidate($params): void + { + // Get all files from storage that match the pattern for this screen ID + $screenId = $params['screen_id']; + $language = $params['language']; + $this->compiledManager->deleteScreenCompiledContent($screenId, $language); + } + + /** + * Clear all compiled screen assets + */ + public function clearCompiledAssets(): void + { + $this->compiledManager->clearCompiledAssets(); + } +} diff --git a/ProcessMaker/Cache/Screens/ScreenCache.php b/ProcessMaker/Cache/Screens/ScreenCache.php new file mode 100644 index 0000000000..ba408916d3 --- /dev/null +++ b/ProcessMaker/Cache/Screens/ScreenCache.php @@ -0,0 +1,30 @@ +label = $screen->getPrometheusMetricLabel(); + + return $self; + } + + /** + * Returns a legible or friendly name for Prometheus metrics. + * + * @return string + */ + public function getPrometheusMetricLabel(): string + { + return $this->label; + } +} 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)); + } + + /** + * Get the current screen cache instance + * + * @return CacheInterface + */ + public static function getScreenCache(): CacheInterface + { + return static::getInstance(); + } +} diff --git a/ProcessMaker/Cache/Screens/ScreenCacheManager.php b/ProcessMaker/Cache/Screens/ScreenCacheManager.php new file mode 100644 index 0000000000..cdf8e65d30 --- /dev/null +++ b/ProcessMaker/Cache/Screens/ScreenCacheManager.php @@ -0,0 +1,168 @@ +cacheManager = $cacheManager; + $this->screenCompiler = $screenCompiler; + } + + public function createKey(array $params): string + { + // Validate required parameters + if (!isset($params['process_id'], $params['process_version_id'], $params['language'], + $params['screen_id'], $params['screen_version_id'])) { + throw new \InvalidArgumentException('Missing required parameters for screen cache key'); + } + + return sprintf( + 'screen_pid_%d_%d_%s_sid_%d_%d', + $params['process_id'], + $params['process_version_id'], + $params['language'], + $params['screen_id'], + $params['screen_version_id'] + ); + } + + /** + * 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; + } + + /** + * Get a screen from cache, or store the value from the callback if the key exists + * + * @param string $key Screen cache key + * @param callable $callback Callback to generate screen content + * @param null|int|\DateInterval $ttl Time to live + * @return mixed + */ + public function getOrCache(string $key, callable $callback, null|int|\DateInterval $ttl = null): mixed + { + $value = $this->get($key); + + if ($value !== null) { + return $value; + } + + $value = $callback(); + + if ($value === null) { + return $value; + } + + $this->set($key, $value, $ttl); + + return $value; + } + + /** + * 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); + } + + /** + * Invalidate all cache entries for a specific screen + * @param int $screenId Screen ID + * @param string $language Language code + * @return bool + */ + public function invalidate($params): void + { + // Get all keys from cache that match the pattern for this screen ID + // TODO Improve this to avoid scanning the entire cache + //extract the params from the array + $screenId = $params['screen_id']; + $language = $params['language']; + $pattern = "*_{$language}_sid_{$screenId}_*"; + $keys = $this->cacheManager->get($pattern); + + // Delete all matching keys + foreach ($keys as $key) { + $this->cacheManager->forget($key); + } + } +} diff --git a/ProcessMaker/Cache/Settings/SettingCacheException.php b/ProcessMaker/Cache/Settings/SettingCacheException.php new file mode 100644 index 0000000000..bdb0ab7cc2 --- /dev/null +++ b/ProcessMaker/Cache/Settings/SettingCacheException.php @@ -0,0 +1,9 @@ +manager = $cacheManager; + $this->setCacheDriver(); + } + + /** + * Determine and set the cache driver to use. + * + * @param CacheManager $cacheManager + * + * @return void + */ + private function setCacheDriver(): void + { + $defaultCache = config('cache.default'); + $isAvailableConnection = in_array($defaultCache, self::AVAILABLE_CONNECTIONS); + + // Set the cache driver to use + $cacheDriver = $isAvailableConnection ? self::DEFAULT_CACHE_DRIVER : $defaultCache; + // Store the cache driver + $this->cacheManager = $this->manager->store($cacheDriver); + } + + /** + * 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 The generated cache key + */ + public function createKey(array $params): string + { + // Validate required parameters + if (!isset($params['key'])) { + throw new \InvalidArgumentException('Missing required parameters for settings cache key'); + } + + return sprintf( + 'setting_%s', + $params['key'] + ); + } + + /** + * Dynamically pass method calls to the cache manager. + * + * @param string $method + * @param array $arguments + * @return mixed + */ + public function __call($method, $arguments): mixed + { + return $this->cacheManager->$method(...$arguments); + } + + /** + * Get a value from the settings cache. + * + * @param string $key + * @param mixed $default + * + * @return mixed + */ + public function get(string $key, mixed $default = null): mixed + { + return $this->cacheManager->get($key, $default); + } + + /** + * Get a value from the settings cache, or store the value from the callback if the key exists. + * + * @param string $key + * @param callable $callback + * + * @return mixed + * + * @throws \InvalidArgumentException + */ + public function getOrCache(string $key, callable $callback): mixed + { + $value = $this->get($key); + + if ($value !== null) { + return $value; + } + + try { + $value = $callback(); + + if ($value === null) { + throw new \InvalidArgumentException('The key does not exist.'); + } + } catch (\Exception $e) { + throw new \InvalidArgumentException('The key does not exist.'); + } + + $this->set($key, $value); + + return $value; + } + + /** + * Store a value in the settings cache. + * + * @param string $key + * @param mixed $value + * @param null|int|\DateInterval $ttl + * + * @return bool + */ + public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + { + return $this->cacheManager->put($key, $value, $ttl); + } + + /** + * Delete a value from the settings cache. + * + * @param string $key + * + * @return bool + */ + public function delete(string $key): bool + { + return $this->cacheManager->forget($key); + } + + /** + * Clear the settings cache. + * + * @return bool + */ + public function clear(): bool + { + return $this->cacheManager->clear(); + } + + /** + * Remove items from the settings cache by a given pattern. + * + * @param string $pattern + * + * @throws \Exception + * @return void + */ + public function clearBy(string $pattern): void + { + $defaultDriver = $this->manager->getDefaultDriver(); + + if ($defaultDriver !== 'cache_settings') { + throw new SettingCacheException('The cache driver must be Redis.'); + } + + try { + $prefix = $this->manager->getPrefix(); + // Filter keys by pattern + $matchedKeys = $this->getKeysByPattern($pattern, $defaultDriver, $prefix); + + if (!empty($matchedKeys)) { + Redis::connection($defaultDriver)->del($matchedKeys); + } + } catch (\Exception $e) { + Log::error('SettingCacheException' . $e->getMessage()); + + throw new SettingCacheException('Failed to delete keys.'); + } + } + + /** + * Check if a value exists in the settings cache. + * + * @param string $key + * + * @return bool + */ + public function has(string $key): bool + { + return $this->cacheManager->has($key); + } + + /** + * Check if a value is missing from the settings cache. + * + * @param string $key + * + * @return bool + */ + public function missing(string $key): bool + { + return !$this->has($key); + } + + /** + * Invalidate a value in the settings cache. + * + * @param string $key + * + * @return void + */ + public function invalidate($params): void + { + try { + //extract the params from the array + $key = $params['key']; + $this->cacheManager->forget($key); + } catch (\Exception $e) { + Log::error($e->getMessage()); + + throw new SettingCacheException('Failed to invalidate cache KEY:' . $key); + } + } +} diff --git a/ProcessMaker/Casts/MillisecondsToDateCast.php b/ProcessMaker/Casts/MillisecondsToDateCast.php new file mode 100644 index 0000000000..100f8673a0 --- /dev/null +++ b/ProcessMaker/Casts/MillisecondsToDateCast.php @@ -0,0 +1,40 @@ + $attributes + * + * @return Carbon|null + */ + public function get(Model $model, string $key, mixed $value, array $attributes): Carbon|null + { + return $value ? Carbon::createFromTimestampMs($value) : null; + } + + /** + * Prepare the given value for storage. + * + * @param Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * + * @return float|null + */ + public function set(Model $model, string $key, mixed $value, array $attributes): float|null + { + return $value ? Carbon::parse($value)->valueOf() : null; + } +} diff --git a/ProcessMaker/Console/Commands/CacheMetricsClearCommand.php b/ProcessMaker/Console/Commands/CacheMetricsClearCommand.php new file mode 100644 index 0000000000..0046859679 --- /dev/null +++ b/ProcessMaker/Console/Commands/CacheMetricsClearCommand.php @@ -0,0 +1,33 @@ +metrics = $metrics; + } + + public function handle() + { + if (!$this->confirm('Are you sure you want to clear all cache metrics? This action cannot be undone.')) { + $this->info('Operation cancelled.'); + + return 0; + } + $this->info('Clearing all cache metrics data...'); + $this->metrics->resetMetrics(); + $this->info('Cache metrics data cleared successfully!'); + } +} diff --git a/ProcessMaker/Console/Commands/CacheMetricsCommand.php b/ProcessMaker/Console/Commands/CacheMetricsCommand.php new file mode 100644 index 0000000000..d78ecfd1ee --- /dev/null +++ b/ProcessMaker/Console/Commands/CacheMetricsCommand.php @@ -0,0 +1,217 @@ +metrics = $metrics; + } + + public function handle() + { + $key = $this->option('key'); + $type = $this->option('type'); + $format = $this->option('format'); + + if ($key) { + $this->displayKeyMetrics($key, $format); + } else { + $this->displaySummary($type, $format); + } + + return 0; + } + + protected function displayKeyMetrics(string $key, string $format): void + { + $hitRate = $this->metrics->getHitRate($key); + $missRate = $this->metrics->getMissRate($key); + $avgHitTime = $this->metrics->getHitAvgTime($key); + $avgMissTime = $this->metrics->getMissAvgTime($key); + $memory = $this->metrics->getMemoryUsage($key); + + $data = [ + 'key' => $key, + 'hit_rate' => $hitRate, + 'miss_rate' => $missRate, + 'avg_hit_time' => $avgHitTime, + 'avg_miss_time' => $avgMissTime, + 'memory_usage' => $memory, + ]; + + if ($format === 'json') { + $this->line(json_encode($data, JSON_PRETTY_PRINT)); + + return; + } + + $this->info("Cache Metrics for key: {$key}"); + $this->newLine(); + + $table = new Table($this->output); + $table->setHeaders(['Metric', 'Value']); + $table->setRows([ + ['Hit Rate', $this->formatPercentage($hitRate)], + ['Miss Rate', $this->formatPercentage($missRate)], + ['Avg Hit Time', sprintf('%.4f sec', $avgHitTime)], + ['Avg Miss Time', sprintf('%.4f sec', $avgMissTime)], + ['Memory Usage', $this->formatBytes($memory['current_size'])], + ['Last Write', $memory['last_write'] ? date('Y-m-d H:i:s', $memory['last_write']) : 'Never'], + ]); + $table->render(); + } + + protected function displaySummary(?string $type, string $format): void + { + $summary = $this->metrics->getSummary(); + + if ($type) { + $summary = $this->filterByType($summary, $type); + } + + if ($format === 'json') { + $this->line(json_encode($summary, JSON_PRETTY_PRINT)); + + return; + } + + $this->info('Cache Performance Summary'); + $this->newLine(); + + // Overall Statistics + $table = new Table($this->output); + $table->setHeaders(['Metric', 'Value']); + $table->setRows([ + ['Total Keys', number_format($summary['total_keys'])], + ['Overall Hit Ratio', $this->formatPercentage($summary['overall_hit_ratio'])], + ['Overall Miss Ratio', $this->formatPercentage($summary['overall_miss_ratio'])], + ['Average Hit Time', sprintf('%.4f sec', $summary['avg_hit_time'])], + ['Average Miss Time', sprintf('%.4f sec', $summary['avg_miss_time'])], + ['Total Memory Usage', $this->formatBytes($summary['total_memory_usage'])], + ]); + $table->render(); + + // Display key details if there are any + if (!empty($summary['keys'])) { + $this->newLine(); + $this->info('Key Details'); + $this->newLine(); + + $table = new Table($this->output); + $table->setHeaders([ + 'Key', + 'Hits', + 'Hit Ratio', + 'Misses', + 'Miss Ratio', + 'Avg Time', + 'Memory', + 'Status', + ]); + + foreach ($summary['keys'] as $key => $metrics) { + $table->addRow([ + $key, + number_format($metrics['hits']), + $this->formatPercentage($metrics['hit_ratio']), + number_format($metrics['misses']), + $this->formatPercentage($metrics['miss_ratio']), + sprintf('%.4f sec', $metrics['avg_hit_time']), + $this->formatBytes($metrics['memory_usage']), + $this->getPerformanceStatus($metrics['hit_ratio']), + ]); + } + + $table->render(); + } + } + + protected function filterByType(array $summary, string $type): array + { + $filtered = [ + 'keys' => [], + 'total_keys' => 0, + 'overall_hit_ratio' => 0, + 'overall_miss_ratio' => 0, + 'avg_hit_time' => 0, + 'avg_miss_time' => 0, + 'total_memory_usage' => 0, + ]; + + foreach ($summary['keys'] as $key => $metrics) { + if ($this->getKeyType($key) === $type) { + $filtered['keys'][$key] = $metrics; + $filtered['total_keys']++; + $filtered['total_memory_usage'] += $metrics['memory_usage']; + $filtered['avg_hit_time'] += $metrics['avg_hit_time']; + $filtered['avg_miss_time'] += $metrics['avg_miss_time']; + } + } + + if ($filtered['total_keys'] > 0) { + $filtered['avg_hit_time'] /= $filtered['total_keys']; + $filtered['avg_miss_time'] /= $filtered['total_keys']; + } + + return $filtered; + } + + protected function getKeyType(string $key): string + { + if (str_starts_with($key, 'screen_')) { + return 'legacy'; + } + if (str_starts_with($key, 'pid_')) { + return 'new'; + } + + return 'unknown'; + } + + protected function formatPercentage(float $value): string + { + return sprintf('%.2f%%', $value * 100); + } + + protected function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + $bytes /= pow(1024, $pow); + + return sprintf('%.2f %s', $bytes, $units[$pow]); + } + + protected function getPerformanceStatus(float $hitRatio): string + { + if ($hitRatio >= 0.95) { + return '✅ Excellent'; + } + if ($hitRatio >= 0.80) { + return '✓ Good'; + } + if ($hitRatio >= 0.60) { + return '⚠️ Fair'; + } + + return '❌ Poor'; + } +} diff --git a/ProcessMaker/Console/Commands/CacheMetricsPopulateCommand.php b/ProcessMaker/Console/Commands/CacheMetricsPopulateCommand.php new file mode 100644 index 0000000000..95cf3a1f43 --- /dev/null +++ b/ProcessMaker/Console/Commands/CacheMetricsPopulateCommand.php @@ -0,0 +1,111 @@ +metrics = $metrics; + } + + public function handle() + { + $numKeys = (int) $this->option('keys'); + $type = $this->option('type'); + + $this->info("Populating {$numKeys} fake cache metrics..."); + + // Reset existing metrics + $this->metrics->resetMetrics(); + + // Generate keys based on type + $keys = $this->generateKeys($numKeys, $type); + + // Populate metrics for each key + foreach ($keys as $key) { + $this->populateKeyMetrics($key); + $this->output->write('.'); + } + + $this->newLine(); + $this->info('Done! You can now run cache:metrics to see the data.'); + } + + protected function generateKeys(int $count, string $type): array + { + $keys = []; + $prefixes = $this->getPrefixes($type); + + for ($i = 0; $i < $count; $i++) { + $prefix = $prefixes[array_rand($prefixes)]; + $id = rand(1, 1000); + + if ($prefix === 'screen_') { + $keys[] = "screen_{$id}_version_" . rand(1, 5); + } else { + $keys[] = "pid_{$id}_{$id}_en_sid_{$id}_" . rand(1, 5); + } + } + + return $keys; + } + + protected function getPrefixes(string $type): array + { + return match ($type) { + 'legacy' => ['screen_'], + 'new' => ['pid_'], + default => ['screen_', 'pid_'], + }; + } + + protected function populateKeyMetrics(string $key): void + { + // Generate random number of hits (0-1000) + $hits = rand(0, 1000); + + // Generate random number of misses (0-200) + $misses = rand(0, 200); + + // Record hits + for ($i = 0; $i < $hits; $i++) { + $time = $this->generateHitTime(); + $this->metrics->recordHit($key, $time); + } + + // Record misses + for ($i = 0; $i < $misses; $i++) { + $time = $this->generateMissTime(); + $this->metrics->recordMiss($key, $time); + } + + // Record memory usage (10KB - 1MB) + $size = rand(10 * 1024, 1024 * 1024); + $this->metrics->recordWrite($key, $size); + } + + protected function generateHitTime(): float + { + // Generate hit time between 0.001 and 0.1 seconds + return rand(1000, 100000) / 1000000; + } + + protected function generateMissTime(): float + { + // Generate miss time between 0.1 and 1 second + return rand(100000, 1000000) / 1000000; + } +} diff --git a/ProcessMaker/Console/Commands/CacheMetricsSummaryCommand.php b/ProcessMaker/Console/Commands/CacheMetricsSummaryCommand.php new file mode 100644 index 0000000000..11d65c1cdf --- /dev/null +++ b/ProcessMaker/Console/Commands/CacheMetricsSummaryCommand.php @@ -0,0 +1,169 @@ +metrics = $metrics; + } + + public function handle() + { + $days = (int) $this->option('days'); + $type = $this->option('type'); + $format = $this->option('format'); + + $summary = $this->metrics->getSummary(); + $topKeys = $this->metrics->getTopKeys(10); + + if ($format === 'json') { + $this->outputJson($summary, $topKeys); + + return; + } + + $this->outputTable($summary, $topKeys); + } + + protected function outputJson(array $summary, array $topKeys): void + { + $data = [ + 'summary' => $summary, + 'top_keys' => $topKeys, + 'generated_at' => now()->toIso8601String(), + ]; + + $this->line(json_encode($data, JSON_PRETTY_PRINT)); + } + + protected function outputTable(array $summary, array $topKeys): void + { + // Overall Statistics + $this->info('Cache Performance Summary'); + $this->newLine(); + + $table = new Table($this->output); + $table->setHeaders(['Metric', 'Value']); + $table->setRows([ + ['Total Keys', number_format($summary['total_keys'])], + ['Overall Hit Ratio', $this->formatPercentage($summary['overall_hit_ratio'])], + ['Overall Miss Ratio', $this->formatPercentage($summary['overall_miss_ratio'])], + ['Average Hit Time', sprintf('%.4f sec', $summary['avg_hit_time'])], + ['Average Miss Time', sprintf('%.4f sec', $summary['avg_miss_time'])], + ['Total Memory Usage', $this->formatBytes($summary['total_memory_usage'])], + ]); + $table->render(); + + // Performance Insights + $this->newLine(); + $this->info('Performance Insights'); + $this->newLine(); + + $insights = $this->generateInsights($summary); + foreach ($insights as $insight) { + $this->line(" • {$insight}"); + } + + // Top Keys + $this->newLine(); + $this->info('Top 10 Most Accessed Keys'); + $this->newLine(); + + $table = new Table($this->output); + $table->setHeaders([ + 'Key', + 'Hits', + 'Hit Ratio', + 'Avg Time', + 'Memory', + 'Status', + ]); + + foreach ($topKeys as $key => $metrics) { + $table->addRow([ + $key, + number_format($metrics['hits']), + $this->formatPercentage($metrics['hit_ratio']), + sprintf('%.4f sec', $metrics['avg_hit_time']), + $this->formatBytes($metrics['memory_usage']), + $this->getPerformanceStatus($metrics['hit_ratio']), + ]); + } + + $table->render(); + } + + protected function generateInsights(array $summary): array + { + $insights = []; + + // Hit ratio insights + if ($summary['overall_hit_ratio'] < 0.5) { + $insights[] = 'Low hit ratio indicates potential cache configuration issues'; + } elseif ($summary['overall_hit_ratio'] > 0.9) { + $insights[] = 'Excellent hit ratio shows effective cache utilization'; + } + + // Response time insights + if ($summary['avg_miss_time'] > $summary['avg_hit_time'] * 10) { + $insights[] = 'High miss penalty suggests optimization opportunities'; + } + + // Memory usage insights + $avgMemoryPerKey = $summary['total_memory_usage'] / ($summary['total_keys'] ?: 1); + if ($avgMemoryPerKey > 1024 * 1024) { // 1MB per key + $insights[] = 'High average memory usage per key'; + } + + return $insights; + } + + protected function formatPercentage(float $value): string + { + return sprintf('%.2f%%', $value * 100); + } + + protected function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + $bytes /= pow(1024, $pow); + + return sprintf('%.2f %s', $bytes, $units[$pow]); + } + + protected function getPerformanceStatus(float $hitRatio): string + { + if ($hitRatio >= 0.95) { + return '✅ Excellent'; + } + if ($hitRatio >= 0.80) { + return '✓ Good'; + } + if ($hitRatio >= 0.60) { + return '⚠️ Fair'; + } + + return '❌ Poor'; + } +} diff --git a/ProcessMaker/Console/Commands/CacheSettingClear.php b/ProcessMaker/Console/Commands/CacheSettingClear.php new file mode 100644 index 0000000000..86afc59556 --- /dev/null +++ b/ProcessMaker/Console/Commands/CacheSettingClear.php @@ -0,0 +1,32 @@ +info('Settings cache cleared.'); + } +} diff --git a/ProcessMaker/Console/Commands/RevokeOauthAccessTokens.php b/ProcessMaker/Console/Commands/RevokeOauthAccessTokens.php index c93a6a02fb..c353097921 100644 --- a/ProcessMaker/Console/Commands/RevokeOauthAccessTokens.php +++ b/ProcessMaker/Console/Commands/RevokeOauthAccessTokens.php @@ -2,10 +2,10 @@ namespace ProcessMaker\Console\Commands; +use Carbon\Carbon; use Illuminate\Console\Command; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; -use Carbon\Carbon; class RevokeOauthAccessTokens extends Command { @@ -37,11 +37,13 @@ public function handle() if (!$name && !$after && !$clientId) { $this->error('At least one of --name, --after, or --client_id must be specified.'); + return; } if (!$noInteraction && !$this->confirm('Are you sure you want to revoke this certificate?')) { $this->info('Certificate revocation cancelled.'); + return; } diff --git a/ProcessMaker/Console/Kernel.php b/ProcessMaker/Console/Kernel.php index b4bb48cf3e..30ac716641 100644 --- a/ProcessMaker/Console/Kernel.php +++ b/ProcessMaker/Console/Kernel.php @@ -41,6 +41,9 @@ protected function schedule(Schedule $schedule) $schedule->command('processmaker:sync-screen-templates --queue') ->daily(); + + $schedule->command('cache:metrics --format=json > storage/logs/processmaker-cache-metrics.json') + ->daily(); } /** diff --git a/ProcessMaker/Contracts/CaseApiRepositoryInterface.php b/ProcessMaker/Contracts/CaseApiRepositoryInterface.php index b71ffdd0d2..ccf978aaa6 100644 --- a/ProcessMaker/Contracts/CaseApiRepositoryInterface.php +++ b/ProcessMaker/Contracts/CaseApiRepositoryInterface.php @@ -15,6 +15,7 @@ interface CaseApiRepositoryInterface * @return Builder */ public function getAllCases(Request $request): Builder; + /** * Get all cases in progress * @@ -23,6 +24,7 @@ public function getAllCases(Request $request): Builder; * @return Builder */ public function getInProgressCases(Request $request): Builder; + /** * Get all completed cases * @@ -31,6 +33,7 @@ public function getInProgressCases(Request $request): Builder; * @return Builder */ public function getCompletedCases(Request $request): Builder; + /** * Search by case number or case title. @@ -40,6 +43,7 @@ public function getCompletedCases(Request $request): Builder; * @return void */ public function search(Request $request, Builder $query): void; + /** * Filter the query. * @@ -50,6 +54,7 @@ public function search(Request $request, Builder $query): void; * @return void */ public function filterBy(Request $request, Builder $query): void; + /** * Sort the query. * diff --git a/ProcessMaker/Contracts/CaseRepositoryInterface.php b/ProcessMaker/Contracts/CaseRepositoryInterface.php index 9ad880cb1b..42ab6f9ad2 100644 --- a/ProcessMaker/Contracts/CaseRepositoryInterface.php +++ b/ProcessMaker/Contracts/CaseRepositoryInterface.php @@ -14,6 +14,7 @@ interface CaseRepositoryInterface * @return void */ public function create(ExecutionInstanceInterface $instance): void; + /** * Update the case started. * @@ -22,6 +23,7 @@ public function create(ExecutionInstanceInterface $instance): void; * @return void */ public function update(ExecutionInstanceInterface $instance, TokenInterface $token): void; + /** * Update the status of a case started. * diff --git a/ProcessMaker/Contracts/PrometheusMetricInterface.php b/ProcessMaker/Contracts/PrometheusMetricInterface.php new file mode 100644 index 0000000000..dc366e2644 --- /dev/null +++ b/ProcessMaker/Contracts/PrometheusMetricInterface.php @@ -0,0 +1,13 @@ +data = $cipherText; - $record->save(); + $record->save(); } } @@ -121,7 +123,7 @@ public function getIv(): string /** * Build transit API object * - * @return \VaultPHP\SecretEngines\Engines\Transit\Transit + * @return Transit */ private function buildTransitApi() { diff --git a/ProcessMaker/Events/TranslationChanged.php b/ProcessMaker/Events/TranslationChanged.php new file mode 100644 index 0000000000..792a78c4ab --- /dev/null +++ b/ProcessMaker/Events/TranslationChanged.php @@ -0,0 +1,31 @@ +language = $language; + $this->changes = $changes; + $this->screenId = $screenId; + } +} diff --git a/ProcessMaker/Facades/Metrics.php b/ProcessMaker/Facades/Metrics.php new file mode 100644 index 0000000000..0867b69ded --- /dev/null +++ b/ProcessMaker/Facades/Metrics.php @@ -0,0 +1,27 @@ +clearCompiledAssets(); return response([], 204); } diff --git a/ProcessMaker/Http/Controllers/Api/SettingController.php b/ProcessMaker/Http/Controllers/Api/SettingController.php index 4561d72bb3..de90e2a461 100644 --- a/ProcessMaker/Http/Controllers/Api/SettingController.php +++ b/ProcessMaker/Http/Controllers/Api/SettingController.php @@ -6,6 +6,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; +use ProcessMaker\Cache\Settings\SettingCacheFactory; use ProcessMaker\Events\SettingsUpdated; use ProcessMaker\Http\Controllers\Controller; use ProcessMaker\Http\Resources\ApiCollection; @@ -252,6 +253,15 @@ public function update(Setting $setting, Request $request) // Register the Event SettingsUpdated::dispatch($setting, $setting->getChanges(), $original); + // Store the setting in the cache + $settingCache = SettingCacheFactory::getSettingsCache(); + //create key + $key = $settingCache->createKey([ + 'key' => $setting->key, + ]); + // set to cache with key and setting + $settingCache->set($key, $setting->refresh()); + return response([], 204); } diff --git a/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php b/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php index 41dab2cf46..334c109077 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::getScreenCache(); + // Create cache key + $key = $screenCache->createKey([ + 'process_id' => (int) $processId, + 'process_version_id' => (int) $processVersionId, + 'language' => $language, + 'screen_id' => (int) $screenVersion->screen_id, + 'screen_version_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/ProcessMaker/Http/Controllers/TaskController.php b/ProcessMaker/Http/Controllers/TaskController.php index fa4c945454..d45ffbb12a 100755 --- a/ProcessMaker/Http/Controllers/TaskController.php +++ b/ProcessMaker/Http/Controllers/TaskController.php @@ -78,12 +78,12 @@ public function edit(ProcessRequestToken $task, string $preview = '') MarkNotificationAsRead::dispatch([['url', '=', '/' . Request::path()]], ['read_at' => Carbon::now()]); $manager = app(ScreenBuilderManager::class); - event(new ScreenBuilderStarting($manager, $task->getScreenVersion() ? $task->getScreenVersion()->type : 'FORM')); + $screenVersion = $task->getScreenVersion(); + event(new ScreenBuilderStarting($manager, $screenVersion ? $screenVersion->type : 'FORM')); $submitUrl = route('api.tasks.update', $task->id); $task->processRequest; $task->user; - $screenVersion = $task->getScreenVersion(); $task->component = $screenVersion ? $screenVersion->parent->renderComponent() : null; $task->screen = $screenVersion ? $screenVersion->toArray() : null; $task->request_data = $dataManager->getData($task); @@ -98,6 +98,11 @@ public function edit(ProcessRequestToken $task, string $preview = '') $screenFields = $screenVersion ? $screenVersion->screenFilteredFields() : []; $taskDraftsEnabled = TaskDraft::draftsEnabled(); + // Remove screen parent to reduce the size of the response + $screen = $task->screen; + $screen['parent'] = null; + $task->screen = $screen; + if ($element instanceof ScriptTaskInterface) { return redirect(route('requests.show', ['request' => $task->processRequest->getKey()])); } else { diff --git a/ProcessMaker/ImportExport/Utils.php b/ProcessMaker/ImportExport/Utils.php index ef96f7f918..750c48bbe5 100644 --- a/ProcessMaker/ImportExport/Utils.php +++ b/ProcessMaker/ImportExport/Utils.php @@ -41,7 +41,7 @@ public static function getElementByPath($document, $path) { $elements = self::getElementsByPath($document, $path); if ($elements->count() !== 1) { - throw new \Exception('Invalid xpath'); + throw new Exception('Invalid xpath'); } return $elements->item(0); diff --git a/ProcessMaker/InboxRules/MatchingTasks.php b/ProcessMaker/InboxRules/MatchingTasks.php index 8170ccc898..a8b659a0fa 100644 --- a/ProcessMaker/InboxRules/MatchingTasks.php +++ b/ProcessMaker/InboxRules/MatchingTasks.php @@ -15,7 +15,7 @@ class MatchingTasks { /** - * @param \ProcessMaker\Models\ProcessRequestToken $task + * @param ProcessRequestToken $task * * @return array */ @@ -62,9 +62,9 @@ public function matchesSavedSearch($rule, $task): bool } /** - * @param \ProcessMaker\Models\InboxRule $inboxRule + * @param InboxRule $inboxRule * - * @return \Illuminate\Support\Collection + * @return Collection */ public function get(InboxRule $inboxRule) : Collection { diff --git a/ProcessMaker/Jobs/CompleteActivity.php b/ProcessMaker/Jobs/CompleteActivity.php index 0f3158faa4..eb3d3547a2 100644 --- a/ProcessMaker/Jobs/CompleteActivity.php +++ b/ProcessMaker/Jobs/CompleteActivity.php @@ -3,7 +3,7 @@ namespace ProcessMaker\Jobs; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Database\DatabaseManager; +use ProcessMaker\Facades\Metrics; use ProcessMaker\Managers\DataManager; use ProcessMaker\Models\Process as Definitions; use ProcessMaker\Models\ProcessRequestToken; @@ -46,5 +46,23 @@ public function action(ProcessRequestToken $token, ActivityInterface $element, a $manager->updateData($token, $data); $this->engine->runToNextState(); $element->complete($token); + + Metrics::counter( + 'activity_completed_total', + 'Total number of activities completed', + [ + 'activity_id', + 'activity_name', + 'process_id', + 'request_id', + ] + )->inc( + [ + 'activity_id' => $element->getId(), + 'activity_name' => $element->getName(), + 'process_id' => $this->definitionsId, + 'request_id' => $this->instanceId, + ] + ); } } diff --git a/ProcessMaker/Listeners/BpmnSubscriber.php b/ProcessMaker/Listeners/BpmnSubscriber.php index 6eef4e1010..239bc94659 100644 --- a/ProcessMaker/Listeners/BpmnSubscriber.php +++ b/ProcessMaker/Listeners/BpmnSubscriber.php @@ -8,6 +8,7 @@ use ProcessMaker\Events\ActivityAssigned; use ProcessMaker\Events\ActivityCompleted; use ProcessMaker\Events\ProcessCompleted; +use ProcessMaker\Facades\Metrics; use ProcessMaker\Facades\WorkflowManager; use ProcessMaker\Jobs\TerminateRequestEndEvent; use ProcessMaker\Models\Comment; @@ -144,6 +145,32 @@ public function onActivityCompleted(ActivityCompletedEvent $event) } Log::info('Activity completed: ' . json_encode($token->getProperties())); + // Prometheus Metric: Activity Execution Time + $startTime = $token->created_at_ms; + $completedTime = $token->completed_at_ms; + $executionTime = $completedTime->diffInMilliseconds($startTime); + Metrics::histogram( + 'activity_execution_time_seconds', + 'Activity Execution Time', + [ + 'activity_id', + 'activity_name', + 'element_type', + 'process_id', + 'request_id', + ], + [1, 10, 3600, 86400] + )->observe( + $executionTime, + [ + 'activity_id' => $token->element_id, + 'activity_name' => $token->element_name, + 'element_type' => $token->element_type, + 'process_id' => $token->process_id, + 'request_id' => $token->process_request_id, + ] + ); + if ($token->element_type == 'task') { $notifiables = $token->getNotifiables('completed'); Notification::send($notifiables, new ActivityCompletedNotification($token)); diff --git a/ProcessMaker/Listeners/InvalidateScreenCacheOnTranslationChange.php b/ProcessMaker/Listeners/InvalidateScreenCacheOnTranslationChange.php new file mode 100644 index 0000000000..e4da61b70f --- /dev/null +++ b/ProcessMaker/Listeners/InvalidateScreenCacheOnTranslationChange.php @@ -0,0 +1,59 @@ +screenId) { + $params = [ + 'screen_id' => $event->screenId, + 'language' => $event->language, + ]; + ScreenCacheFactory::getScreenCache()->invalidate($params); + } + } catch (\Exception $e) { + Log::error('Failed to invalidate screen cache', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'language' => $event->language, + 'screenId' => $event->screenId, + ]); + throw $e; // Re-throw to ensure error is properly handled + } + } + + /** + * Invalidate cache for a specific screen + */ + protected function invalidateScreen(string $screenId, string $locale): void + { + try { + $screen = Screen::find($screenId); + if ($screen) { + $cache = ScreenCacheFactory::getScreenCache(); + $cache->invalidate($screen->id, $locale); + } else { + Log::warning('Screen not found', ['screenId' => $screenId]); + } + } catch (\Exception $e) { + Log::error('Error in invalidateScreen', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'screenId' => $screenId, + 'locale' => $locale, + ]); + throw $e; + } + } +} diff --git a/ProcessMaker/Mail/TaskActionByEmail.php b/ProcessMaker/Mail/TaskActionByEmail.php index 65ed8a40d7..158807368f 100644 --- a/ProcessMaker/Mail/TaskActionByEmail.php +++ b/ProcessMaker/Mail/TaskActionByEmail.php @@ -38,7 +38,7 @@ public function sendAbeEmail($config, $to, $data) $emailServer = $config['emailServer'] ?? 0; $subject = $config['subject'] ?? ''; $emailScreenRef = $config['screenEmailRef'] ?? 0; - + $emailConfig = [ 'subject' => $this->mustache($subject, $data), 'addEmails' => $to, @@ -47,7 +47,7 @@ public function sendAbeEmail($config, $to, $data) 'json_data' => '{}', 'emailServer' => $emailServer, ]; - + if (!empty($emailScreenRef)) { // Retrieve and render custom screen if specified $customScreen = Screen::findOrFail($emailScreenRef); @@ -56,10 +56,9 @@ public function sendAbeEmail($config, $to, $data) // Default message if no custom screen is configured $emailConfig['body'] = __('No screen configured'); } - + // Send the email using emailProvider $this->emailProvider->send($emailConfig); - } catch (\Exception $e) { Log::error('Error sending ABE email', [ 'to' => $to, diff --git a/ProcessMaker/Managers/ModelerManager.php b/ProcessMaker/Managers/ModelerManager.php index 5c847233c2..e762e3ef23 100644 --- a/ProcessMaker/Managers/ModelerManager.php +++ b/ProcessMaker/Managers/ModelerManager.php @@ -5,6 +5,7 @@ class ModelerManager { private $javascriptRegistry; + private $javascriptParamsRegistry; /** @@ -48,6 +49,7 @@ public function getScripts() { return $this->javascriptRegistry; } + /** * Retrieve the JavaScript parameters registry. * diff --git a/ProcessMaker/Managers/ScreenCompiledManager.php b/ProcessMaker/Managers/ScreenCompiledManager.php index 2a26b53acd..bfbc7c01a6 100644 --- a/ProcessMaker/Managers/ScreenCompiledManager.php +++ b/ProcessMaker/Managers/ScreenCompiledManager.php @@ -106,4 +106,28 @@ protected function getFilename(string $screenKey) { return 'screen_' . $screenKey . '.bin'; } + + /** + * Delete all compiled content for a specific screen ID and language + * + * @param string $screenId Screen ID + * @param string $language Language code + * @return bool + */ + public function deleteScreenCompiledContent(string $screenId, string $language): bool + { + $files = Storage::disk($this->storageDisk)->files($this->storagePath); + $deleted = false; + + foreach ($files as $file) { + // Remove the 'screen_' prefix and '.bin' extension for pattern matching + $filename = str_replace(['screen_', '.bin'], '', basename($file)); + if (strpos($filename, "_{$language}_sid_{$screenId}_") !== false) { + Storage::disk($this->storageDisk)->delete($file); + $deleted = true; + } + } + + return $deleted; + } } diff --git a/ProcessMaker/Managers/SessionControlManager.php b/ProcessMaker/Managers/SessionControlManager.php index 8231c6c2aa..9079b02f60 100644 --- a/ProcessMaker/Managers/SessionControlManager.php +++ b/ProcessMaker/Managers/SessionControlManager.php @@ -16,9 +16,11 @@ class SessionControlManager { const IP_RESTRICTION_KEY = 'session-control.ip_restriction'; + const DEVICE_RESTRICTION_KEY = 'session-control.device_restriction'; private ?User $user; + private string $clientIp; public function __construct(?User $user, string $clientIp) @@ -28,7 +30,6 @@ public function __construct(?User $user, string $clientIp) } /** - * * If the session is blocked by some of the ProcessMaker policies, returns true, false otherwise * * @return bool @@ -47,7 +48,6 @@ public function isSessionBlocked() return false; } - /** * Checks if a user's session is a duplicate based on their IP address. * @@ -57,6 +57,7 @@ public function blockSessionByIp(): bool { // Get the user's most recent session $session = $this->user->sessions->sortByDesc('created_at')->first(); + // Get the user's current IP address return $session->ip_address === $this->clientIp; } @@ -118,4 +119,4 @@ private function formatDeviceInfo(string $deviceName, string $deviceType, string { return Str::slug($deviceName . '-' . $deviceType . '-' . $devicePlatform); } -} \ No newline at end of file +} diff --git a/ProcessMaker/Managers/TaskSchedulerManager.php b/ProcessMaker/Managers/TaskSchedulerManager.php index d866ec421d..67f419e67e 100644 --- a/ProcessMaker/Managers/TaskSchedulerManager.php +++ b/ProcessMaker/Managers/TaskSchedulerManager.php @@ -52,7 +52,7 @@ private function removeExpiredLocks() /** * Register in the database any Timer Start Event of a process * - * @param \ProcessMaker\Models\Process $process + * @param Process $process * @return void * @internal param string $script Path to the javascript to load */ @@ -139,9 +139,9 @@ public function scheduleTasks() $today = $this->today(); try { /** - * This validation is removed; the database schema should exist before + * This validation is removed; the database schema should exist before * any initiation of 'jobs' and 'schedule'. - * + * * if (!Schema::hasTable('scheduled_tasks')) { * return; * } @@ -206,7 +206,7 @@ public function scheduleTasks() } } } catch (PDOException $e) { - Log::error('The connection to the database had problems (scheduleTasks): ' . $e->getMessage()); + Log::error('The connection to the database had problems (scheduleTasks): ' . $e->getMessage()); } } diff --git a/ProcessMaker/Models/Bundle.php b/ProcessMaker/Models/Bundle.php index a53fa3ceef..2dfc66f3ae 100644 --- a/ProcessMaker/Models/Bundle.php +++ b/ProcessMaker/Models/Bundle.php @@ -125,7 +125,7 @@ public function addAsset(ProcessMakerModel $asset) 'asset_id' => $asset->id, ]); } - + public function addAssetToBundles(ProcessMakerModel $asset) { $message = null; @@ -134,6 +134,7 @@ public function addAssetToBundles(ProcessMakerModel $asset) } catch (ValidationException $ve) { $message = $ve->getMessage(); } + return $message; } diff --git a/ProcessMaker/Models/DataStore.php b/ProcessMaker/Models/DataStore.php index 21ad708063..bb7dad4b05 100644 --- a/ProcessMaker/Models/DataStore.php +++ b/ProcessMaker/Models/DataStore.php @@ -21,12 +21,12 @@ class DataStore implements DataStoreInterface private $removed = []; /** - * @var \ProcessMaker\Nayra\Contracts\Bpmn\ProcessInterface + * @var ProcessInterface */ private $process; /** - * @var \ProcessMaker\Nayra\Contracts\Bpmn\ItemDefinitionInterface + * @var ItemDefinitionInterface */ private $itemSubject; @@ -43,7 +43,7 @@ public function getOwnerProcess() /** * Get Process of the application. * - * @param \ProcessMaker\Nayra\Contracts\Bpmn\ProcessInterface $process + * @param ProcessInterface $process * * @return ProcessInterface */ diff --git a/ProcessMaker/Models/Embed.php b/ProcessMaker/Models/Embed.php index 6e74064efa..8a69e92821 100644 --- a/ProcessMaker/Models/Embed.php +++ b/ProcessMaker/Models/Embed.php @@ -4,8 +4,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use ProcessMaker\Models\ProcessMakerModel; -use ProcessMaker\Traits\HasUuids; use ProcessMaker\Traits\Exportable; +use ProcessMaker\Traits\HasUuids; class Embed extends ProcessMakerModel { @@ -28,7 +28,7 @@ class Embed extends ProcessMakerModel 'created_at', 'updated_at', ]; - + /** * The attributes that are mass assignable. * @@ -67,7 +67,7 @@ public function process() */ public function saveProcessEmbed(Process $process, $properties, $key = 'uuid') { - $embed = new Embed(); + $embed = new self(); // Define the values $values = [ 'model_id' => $process->id, @@ -75,7 +75,7 @@ public function saveProcessEmbed(Process $process, $properties, $key = 'uuid') 'mime_type' => 'text/url', 'custom_properties' => json_encode([ 'url' => $properties['url'], - 'type' => $properties['type'] + 'type' => $properties['type'], ]), ]; // Review if the uuid was defined diff --git a/ProcessMaker/Models/EncryptedData.php b/ProcessMaker/Models/EncryptedData.php index 3cd1d77e22..b6c53f5323 100644 --- a/ProcessMaker/Models/EncryptedData.php +++ b/ProcessMaker/Models/EncryptedData.php @@ -27,7 +27,7 @@ class EncryptedData extends ProcessMakerModel 'created_at', 'updated_at', ]; - + /** * The attributes that are mass assignable. * @@ -41,12 +41,12 @@ class EncryptedData extends ProcessMakerModel /** * Check user permission for the encrypted data - * + * * @param string $userId * @param string $screenId * @param string $fieldName * - * @throws \Illuminate\Validation\ValidationException + * @throws ValidationException */ public static function checkUserPermission($userId, $screenId, $fieldName) { diff --git a/ProcessMaker/Models/FormalExpression.php b/ProcessMaker/Models/FormalExpression.php index 8f40df7032..0f42344016 100644 --- a/ProcessMaker/Models/FormalExpression.php +++ b/ProcessMaker/Models/FormalExpression.php @@ -35,7 +35,7 @@ class FormalExpression implements FormalExpressionInterface /** * FEEL expression object to be used to evaluate - * @var \Symfony\Component\ExpressionLanguage\ExpressionLanguage + * @var ExpressionLanguage */ private $feelExpression; diff --git a/ProcessMaker/Models/ProcessAbeRequestToken.php b/ProcessMaker/Models/ProcessAbeRequestToken.php index dd86e275c9..6313d9b1d1 100644 --- a/ProcessMaker/Models/ProcessAbeRequestToken.php +++ b/ProcessMaker/Models/ProcessAbeRequestToken.php @@ -37,7 +37,7 @@ class ProcessAbeRequestToken extends ProcessMakerModel 'data', 'is_answered', 'require_login', - 'answered_at' + 'answered_at', ]; public static function rules(): array diff --git a/ProcessMaker/Models/ProcessLaunchpad.php b/ProcessMaker/Models/ProcessLaunchpad.php index cd82d39bdd..3f9fda1d5f 100644 --- a/ProcessMaker/Models/ProcessLaunchpad.php +++ b/ProcessMaker/Models/ProcessLaunchpad.php @@ -3,8 +3,8 @@ namespace ProcessMaker\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; -use ProcessMaker\Traits\HasUuids; use ProcessMaker\Traits\Exportable; +use ProcessMaker\Traits\HasUuids; class ProcessLaunchpad extends ProcessMakerModel { diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index 9e266fe1cd..46b75c4575 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Notification; use Laravel\Scout\Searchable; use Log; +use ProcessMaker\Casts\MillisecondsToDateCast; use ProcessMaker\Events\ActivityAssigned; use ProcessMaker\Events\ActivityReassignment; use ProcessMaker\Facades\WorkflowUserManager; @@ -45,6 +46,8 @@ * @property Carbon $riskchanges_at * @property Carbon $updated_at * @property Carbon $created_at + * @property Carbon $created_at_ms + * @property Carbon $completed_at_ms * @property ProcessRequest $processRequest * * @OA\Schema( @@ -154,6 +157,8 @@ class ProcessRequestToken extends ProcessMakerModel implements TokenInterface 'token_properties' => 'array', 'is_priority' => 'boolean', 'is_actionbyemail' => 'boolean', + 'created_at_ms' => MillisecondsToDateCast::class, + 'completed_at_ms' => MillisecondsToDateCast::class, ]; /** diff --git a/ProcessMaker/Models/Screen.php b/ProcessMaker/Models/Screen.php index 29a05fc568..4073546922 100644 --- a/ProcessMaker/Models/Screen.php +++ b/ProcessMaker/Models/Screen.php @@ -6,7 +6,9 @@ use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use ProcessMaker\Assets\ScreensInScreen; +use ProcessMaker\Contracts\PrometheusMetricInterface; use ProcessMaker\Contracts\ScreenInterface; +use ProcessMaker\Events\TranslationChanged; use ProcessMaker\Traits\Exportable; use ProcessMaker\Traits\ExtendedPMQL; use ProcessMaker\Traits\HasCategories; @@ -62,7 +64,7 @@ * @OA\Property(property="url", type="string"), * ) */ -class Screen extends ProcessMakerModel implements ScreenInterface +class Screen extends ProcessMakerModel implements ScreenInterface, PrometheusMetricInterface { use SerializeToIso8601; use HideSystemResources; @@ -282,4 +284,14 @@ public function scopeFilter($query, $filterStr) return $query; } + + /** + * Return the label to be used in grafana reports + * + * @return string + */ + public function getPrometheusMetricLabel(): string + { + return 'screen.' . $this->id; + } } diff --git a/ProcessMaker/Models/ScreenVersion.php b/ProcessMaker/Models/ScreenVersion.php index 3448b9386d..ead25ce27b 100644 --- a/ProcessMaker/Models/ScreenVersion.php +++ b/ProcessMaker/Models/ScreenVersion.php @@ -3,11 +3,13 @@ namespace ProcessMaker\Models; use Illuminate\Database\Eloquent\Builder; +use ProcessMaker\Contracts\PrometheusMetricInterface; use ProcessMaker\Contracts\ScreenInterface; +use ProcessMaker\Events\TranslationChanged; use ProcessMaker\Traits\HasCategories; use ProcessMaker\Traits\HasScreenFields; -class ScreenVersion extends ProcessMakerModel implements ScreenInterface +class ScreenVersion extends ProcessMakerModel implements ScreenInterface, PrometheusMetricInterface { use HasCategories; use HasScreenFields; @@ -33,6 +35,14 @@ class ScreenVersion extends ProcessMakerModel implements ScreenInterface 'translations' => 'array', ]; + /** + * Boot the model and its events + */ + public static function boot() + { + parent::boot(); + } + /** * Set multiple|single categories to the screen * @@ -68,4 +78,9 @@ public function scopePublished(Builder $query) { return $query->where('draft', false); } + + public function getPrometheusMetricLabel(): string + { + return 'screen.' . $this->screen_id; + } } diff --git a/ProcessMaker/Models/ScriptDockerCopyingFilesTrait.php b/ProcessMaker/Models/ScriptDockerCopyingFilesTrait.php index 1b57b76c4d..343694d3b9 100644 --- a/ProcessMaker/Models/ScriptDockerCopyingFilesTrait.php +++ b/ProcessMaker/Models/ScriptDockerCopyingFilesTrait.php @@ -19,7 +19,7 @@ trait ScriptDockerCopyingFilesTrait * @param array $options * * @return array - * @throws \RuntimeException + * @throws RuntimeException */ protected function executeCopying(array $options) { @@ -47,7 +47,7 @@ protected function executeCopying(array $options) * @param string $parameters * * @return string - * @throws \RuntimeException + * @throws RuntimeException */ private function createContainer($image, $command, $parameters = '') { @@ -74,7 +74,7 @@ private function createContainer($image, $command, $parameters = '') * @param string $path * @param string $content * - * @throws \RuntimeException + * @throws RuntimeException */ private function putInContainer($container, $path, $content) { @@ -94,7 +94,7 @@ private function putInContainer($container, $path, $content) * @param string $container * @param string $dest * - * @throws \RuntimeException + * @throws RuntimeException */ private function execCopy($source, $container, $dest) { @@ -111,7 +111,7 @@ private function execCopy($source, $container, $dest) * @param string $path * * @return string - * @throws \RuntimeException + * @throws RuntimeException */ private function getFromContainer($container, $path) { diff --git a/ProcessMaker/Models/Setting.php b/ProcessMaker/Models/Setting.php index e6167a2517..7d1b48ff94 100644 --- a/ProcessMaker/Models/Setting.php +++ b/ProcessMaker/Models/Setting.php @@ -7,6 +7,8 @@ use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Log; +use ProcessMaker\Cache\Settings\SettingCacheFactory; +use ProcessMaker\Contracts\PrometheusMetricInterface; use ProcessMaker\Traits\ExtendedPMQL; use ProcessMaker\Traits\SerializeToIso8601; use Spatie\MediaLibrary\HasMedia; @@ -47,7 +49,7 @@ * }, * ) */ -class Setting extends ProcessMakerModel implements HasMedia +class Setting extends ProcessMakerModel implements HasMedia, PrometheusMetricInterface { use ExtendedPMQL; use InteractsWithMedia; @@ -143,12 +145,26 @@ public static function messages() * * @param string $key * - * @return \ProcessMaker\Models\Setting|null + * @return Setting|null * @throws \Exception */ public static function byKey(string $key) { - return (new self)->where('key', $key)->first(); + $settingCache = SettingCacheFactory::getSettingsCache(); + $settingKey = $settingCache->createKey([ + 'key' => $key, + ]); + $exists = $settingCache->has($settingKey); + + // if the setting is not in the cache, get it from the database and store it in the cache + if ($exists) { + $setting = $settingCache->get($settingKey); + } else { + $setting = (new self)->where('key', $key)->first(); + $settingCache->set($settingKey, $setting); + } + + return $setting; } /** @@ -382,7 +398,7 @@ public static function getFavicon() */ public static function groupsByMenu($menuId) { - $query = Setting::query() + $query = self::query() ->select('group') ->groupBy('group') ->where('group_id', $menuId) @@ -409,7 +425,7 @@ public static function groupsByMenu($menuId) */ public static function updateSettingsGroup($settingsGroup, $id) { - Setting::where('group', $settingsGroup)->whereNull('group_id')->chunk( + self::where('group', $settingsGroup)->whereNull('group_id')->chunk( 50, function ($settings) use ($id) { foreach ($settings as $setting) { @@ -429,7 +445,7 @@ function ($settings) use ($id) { */ public static function updateAllSettingsGroupId() { - Setting::whereNull('group_id')->chunk(100, function ($settings) { + self::whereNull('group_id')->chunk(100, function ($settings) { $defaultId = SettingsMenus::EMAIL_MENU_GROUP; foreach ($settings as $setting) { // Define the value of 'menu_group' based on 'group' @@ -482,4 +498,14 @@ public static function updateAllSettingsGroupId() } }); } + + /** + * Get the label used in grafana reports + * + * @return string + */ + public function getPrometheusMetricLabel(): string + { + return 'settings.' . $this->key; + } } diff --git a/ProcessMaker/Models/TimerExpression.php b/ProcessMaker/Models/TimerExpression.php index 8fab7b99e4..af70744e23 100644 --- a/ProcessMaker/Models/TimerExpression.php +++ b/ProcessMaker/Models/TimerExpression.php @@ -113,7 +113,7 @@ private function mustacheTimerExpression($expression, $data) /** * Get a DateTime if the expression is a date. * - * @return \DateTime + * @return DateTime */ protected function getDateExpression($expression) { @@ -180,7 +180,7 @@ protected function getMultipleCycleExpression($expression) /** * Get a DateInterval if the expression is a duration. * - * @return \DateInterval + * @return DateInterval */ protected function getDurationExpression($expression) { diff --git a/ProcessMaker/Models/TokenAssignableUsers.php b/ProcessMaker/Models/TokenAssignableUsers.php index 55140bef13..c452d220d3 100644 --- a/ProcessMaker/Models/TokenAssignableUsers.php +++ b/ProcessMaker/Models/TokenAssignableUsers.php @@ -64,7 +64,7 @@ public function initRelation(array $models, $relation) * Match the eagerly loaded results to their parents. * * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results + * @param Collection $results * @param string $relation * @return array */ diff --git a/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php b/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php index d688f10ad5..0f757aa7e2 100644 --- a/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php +++ b/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php @@ -145,7 +145,7 @@ public function triggerBoundaryEvent( * @param array $data * @param callable $beforeStart * - * @return \ProcessMaker\Models\ProcessRequest + * @return ProcessRequest */ public function triggerStartEvent(Definitions $definitions, StartEventInterface $event, array $data, callable $beforeStart = null) { @@ -163,7 +163,7 @@ public function triggerStartEvent(Definitions $definitions, StartEventInterface * @param ProcessInterface $process * @param array $data * - * @return \ProcessMaker\Models\ProcessRequest + * @return ProcessRequest */ public function callProcess(Definitions $definitions, ProcessInterface $process, array $data) { @@ -410,6 +410,7 @@ public function registerServiceImplementation($implementation, $class) . ' must be an instance of ' . ServiceTaskImplementationInterface::class ); + return false; } diff --git a/ProcessMaker/Observers/ProcessRequestTokenObserver.php b/ProcessMaker/Observers/ProcessRequestTokenObserver.php index 4a5dbd619b..09e6279272 100644 --- a/ProcessMaker/Observers/ProcessRequestTokenObserver.php +++ b/ProcessMaker/Observers/ProcessRequestTokenObserver.php @@ -20,6 +20,11 @@ public function saved(ProcessRequestToken $token) } } + public function creating(ProcessRequestToken $token) + { + $token->created_at_ms = now(); + } + /** * Once a token is saved, it also saves the version reference of the * screen or script executed @@ -29,5 +34,8 @@ public function saved(ProcessRequestToken $token) public function saving(ProcessRequestToken $token) { $token->saveVersion(); + if ($token->completed_at && $token->isDirty('completed_at')) { + $token->completed_at_ms = now(); + } } } diff --git a/ProcessMaker/Observers/SettingObserver.php b/ProcessMaker/Observers/SettingObserver.php index 181913c41e..3ae202d91a 100644 --- a/ProcessMaker/Observers/SettingObserver.php +++ b/ProcessMaker/Observers/SettingObserver.php @@ -4,6 +4,7 @@ use Exception; use Illuminate\Support\Facades\Log; +use ProcessMaker\Cache\Settings\SettingCacheFactory; use ProcessMaker\Models\Setting; class SettingObserver @@ -11,7 +12,7 @@ class SettingObserver /** * Handle the setting "created" event. * - * @param \ProcessMaker\Models\Setting $setting + * @param Setting $setting * @return void */ public function saving(Setting $setting) @@ -39,7 +40,7 @@ public function saving(Setting $setting) try { $return = json_decode($config); $return = json_encode($return); - } catch (\Exception $e) { + } catch (Exception $e) { $return = $config; } } else { @@ -55,7 +56,7 @@ public function saving(Setting $setting) try { $return = json_decode($config, true); $return = json_encode($return); - } catch (\Exception $e) { + } catch (Exception $e) { $return = $config; } } else { @@ -65,5 +66,21 @@ public function saving(Setting $setting) $setting->config = $return; break; } + + $settingCache = SettingCacheFactory::getSettingsCache(); + $settingCache->invalidate(['key' => $setting->key]); + } + + /** + * Handle the setting "deleted" event. + * + * @param Setting $setting + * @return void + */ + public function deleted(Setting $setting): void + { + $settingCache = SettingCacheFactory::getSettingsCache(); + //invalidate the setting cache + $settingCache->invalidate(['key' => $setting->key]); } } diff --git a/ProcessMaker/ProcessTranslations/ScreenTranslation.php b/ProcessMaker/ProcessTranslations/ScreenTranslation.php index d1f1bb1570..f5f129fe7d 100644 --- a/ProcessMaker/ProcessTranslations/ScreenTranslation.php +++ b/ProcessMaker/ProcessTranslations/ScreenTranslation.php @@ -4,6 +4,7 @@ use Illuminate\Support\Collection as SupportCollection; use Illuminate\Support\Facades\Cache; +use ProcessMaker\Events\TranslationChanged; use ProcessMaker\ImportExport\Utils; use ProcessMaker\Models\MustacheExpressionEvaluator; use ProcessMaker\Models\Screen; @@ -29,6 +30,7 @@ public function applyTranslations(ScreenVersionModel $screen, $defaultLanguage = $language = $this->getTargetLanguage($defaultLanguage); + // event(new TranslationChanged($locale, $changes, $screenId)); return $this->searchTranslations($screen->screen_id, $config, $language); } diff --git a/ProcessMaker/Providers/CacheServiceProvider.php b/ProcessMaker/Providers/CacheServiceProvider.php new file mode 100644 index 0000000000..fdd29dbdf3 --- /dev/null +++ b/ProcessMaker/Providers/CacheServiceProvider.php @@ -0,0 +1,55 @@ +app->singleton(ScreenCacheManager::class, function ($app) { + return ScreenCacheFactory::create( + $app['cache'], + $app->make(CacheMetricsInterface::class) + ); + }); + + // Register settings cache with metrics + $this->app->singleton(SettingCacheManager::class, function ($app) { + return SettingCacheFactory::create( + $app['cache'], + $app->make(CacheMetricsInterface::class) + ); + }); + + // Register legacy screen cache with metrics + $this->app->bind(LegacyScreenCacheAdapter::class, function ($app) { + return ScreenCacheFactory::create( + $app['cache'], + $app->make(CacheMetricsInterface::class) + ); + }); + } + + public function boot(): void + { + // Register the metrics commands + if ($this->app->runningInConsole()) { + $this->commands([ + \ProcessMaker\Console\Commands\CacheMetricsCommand::class, + \ProcessMaker\Console\Commands\CacheMetricsSummaryCommand::class, + \ProcessMaker\Console\Commands\CacheMetricsPopulateCommand::class, + \ProcessMaker\Console\Commands\CacheMetricsClearCommand::class, + ]); + } + } +} diff --git a/ProcessMaker/Providers/EncryptedDataServiceProvider.php b/ProcessMaker/Providers/EncryptedDataServiceProvider.php index c378b94a8b..48c7a7c03f 100644 --- a/ProcessMaker/Providers/EncryptedDataServiceProvider.php +++ b/ProcessMaker/Providers/EncryptedDataServiceProvider.php @@ -5,7 +5,7 @@ use Illuminate\Support\ServiceProvider; use ProcessMaker\Managers\EncryptedDataManager; -class EncryptedDataProvider extends ServiceProvider +class EncryptedDataServiceProvider extends ServiceProvider { /** * Register services. diff --git a/ProcessMaker/Providers/EventServiceProvider.php b/ProcessMaker/Providers/EventServiceProvider.php index 47406f931e..5e7a07ed53 100644 --- a/ProcessMaker/Providers/EventServiceProvider.php +++ b/ProcessMaker/Providers/EventServiceProvider.php @@ -55,6 +55,7 @@ use ProcessMaker\Events\TemplateUpdated; use ProcessMaker\Events\TokenCreated; use ProcessMaker\Events\TokenDeleted; +use ProcessMaker\Events\TranslationChanged; use ProcessMaker\Events\UnauthorizedAccessAttempt; use ProcessMaker\Events\UserCreated; use ProcessMaker\Events\UserDeleted; @@ -64,6 +65,7 @@ use ProcessMaker\Listeners\HandleActivityAssignedInterstitialRedirect; use ProcessMaker\Listeners\HandleActivityCompletedRedirect; use ProcessMaker\Listeners\HandleEndEventRedirect; +use ProcessMaker\Listeners\InvalidateScreenCacheOnTranslationChange; use ProcessMaker\Listeners\SecurityLogger; use ProcessMaker\Listeners\SessionControlSettingsUpdated; @@ -106,6 +108,9 @@ class EventServiceProvider extends ServiceProvider ActivityAssigned::class => [ HandleActivityAssignedInterstitialRedirect::class, ], + TranslationChanged::class => [ + InvalidateScreenCacheOnTranslationChange::class, + ], ]; /** diff --git a/ProcessMaker/Providers/MetricsServiceProvider.php b/ProcessMaker/Providers/MetricsServiceProvider.php new file mode 100644 index 0000000000..a55ccdd090 --- /dev/null +++ b/ProcessMaker/Providers/MetricsServiceProvider.php @@ -0,0 +1,27 @@ +app->singleton(MetricsService::class, function ($app) { + return new MetricsService(); + }); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // + } +} diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index f4e2f9be92..6c73a5cdee 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -17,6 +17,7 @@ use Laravel\Horizon\Horizon; use Laravel\Passport\Passport; use Lavary\Menu\Menu; +use ProcessMaker\Cache\Settings\SettingCacheManager; use ProcessMaker\Console\Migration\ExtendedMigrateCommand; use ProcessMaker\Events\ActivityAssigned; use ProcessMaker\Events\ScreenBuilderStarting; @@ -31,6 +32,7 @@ use ProcessMaker\Models; use ProcessMaker\Observers; use ProcessMaker\PolicyExtension; +use RuntimeException; /** * Provide our ProcessMaker specific services. @@ -188,6 +190,14 @@ public function register(): void $this->app->singleton('compiledscreen', function ($app) { return new ScreenCompiledManager(); }); + + $this->app->singleton('setting.cache', function ($app) { + if ($app['config']->get('cache.stores.cache_settings')) { + return new SettingCacheManager($app->make('cache')); + } else { + throw new RuntimeException('Cache configuration is missing.'); + } + }); } /** diff --git a/ProcessMaker/Repositories/CaseTaskRepository.php b/ProcessMaker/Repositories/CaseTaskRepository.php index 83caca2a53..653dfff4fd 100644 --- a/ProcessMaker/Repositories/CaseTaskRepository.php +++ b/ProcessMaker/Repositories/CaseTaskRepository.php @@ -45,7 +45,7 @@ public function updateCaseParticipatedTaskStatus() public function updateTaskStatus() { try { - $case = $this->findCaseByTaskId($this->caseNumber, (string)$this->task->id); + $case = $this->findCaseByTaskId($this->caseNumber, (string) $this->task->id); if (!$case) { Log::error('CaseException: ' . 'Case not found, case_number=' . $this->caseNumber . ', task_id=' . $this->task->id); @@ -80,7 +80,7 @@ public function findCaseByTaskId(int $caseNumber, string $taskId): ?object return DB::table($this->table) ->select([ 'case_number', - DB::raw("JSON_UNQUOTE(JSON_SEARCH(tasks, 'one', ?, NULL, '$[*].id')) as task_index") + DB::raw("JSON_UNQUOTE(JSON_SEARCH(tasks, 'one', ?, NULL, '$[*].id')) as task_index"), ]) ->where('case_number', $caseNumber) ->whereJsonContains('tasks', ['id' => $taskId]) diff --git a/ProcessMaker/Repositories/CaseUtils.php b/ProcessMaker/Repositories/CaseUtils.php index 552c994e3b..49a48f6ff2 100644 --- a/ProcessMaker/Repositories/CaseUtils.php +++ b/ProcessMaker/Repositories/CaseUtils.php @@ -153,7 +153,7 @@ public static function storeTasks(Collection $tasks, ?array $taskData = []): Col ) { unset($taskData['element_type']); // This field is converted to string because: The Json_Search in MySQL only works with strings - $taskData['id'] = (string)$taskData['id']; + $taskData['id'] = (string) $taskData['id']; $tasks->prepend($taskData); } diff --git a/ProcessMaker/Repositories/TokenRepository.php b/ProcessMaker/Repositories/TokenRepository.php index 74a8e79e0b..5f6368c3a4 100644 --- a/ProcessMaker/Repositories/TokenRepository.php +++ b/ProcessMaker/Repositories/TokenRepository.php @@ -134,7 +134,7 @@ public function persistActivityActivated(ActivityInterface $activity, TokenInter $dataManager = new DataManager(); $tokenData = $dataManager->getData($token); $feel = new FeelExpressionEvaluator(); - $evaluatedUsers = $selfServiceUsers ? $feel->render($selfServiceUsers, $tokenData) ?? null: []; + $evaluatedUsers = $selfServiceUsers ? $feel->render($selfServiceUsers, $tokenData) ?? null : []; $evaluatedGroups = $selfServiceGroups ? $feel->render($selfServiceGroups, $tokenData) ?? null : []; // If we have single values we put it inside an array diff --git a/ProcessMaker/Services/MetricsService.php b/ProcessMaker/Services/MetricsService.php new file mode 100644 index 0000000000..1adc166d5c --- /dev/null +++ b/ProcessMaker/Services/MetricsService.php @@ -0,0 +1,145 @@ +namespace = config('app.prometheus_namespace', 'app'); + try { + // Set up Redis as the adapter if none is provided + if ($adapter === null) { + $adapter = new Redis([ + 'host' => config('database.redis.default.host'), + 'port' => config('database.redis.default.port'), + ]); + } + $this->collectionRegistry = new CollectorRegistry($adapter); + } catch (Exception $e) { + throw new RuntimeException('Error initializing the metrics adapter: ' . $e->getMessage()); + } + } + + /** + * Get the collection registry. + * + * @return CollectorRegistry The collection registry instance. + */ + public function getCollectionRegistry(): CollectorRegistry + { + return $this->collectionRegistry; + } + + /** + * Registers or retrieves a counter metric. + * + * @param string $name The name of the counter. + * @param string|null $help The help text of the counter. + * @param array $labels The labels of the counter. + * @return Counter The registered or retrieved counter. + */ + public function counter(string $name, string $help = null, array $labels = []): Counter + { + $help = $help ?? $name; + return $this->collectionRegistry->getOrRegisterCounter( + $this->namespace, + $name, + $help, + $labels + ); + } + + /** + * Registers or retrieves a gauge metric. + * + * @param string $name The name of the gauge. + * @param string|null $help The help text of the gauge. + * @param array $labels The labels of the gauge. + * @return Gauge The registered or retrieved gauge. + */ + public function gauge(string $name, string $help = null, array $labels = []): Gauge + { + $help = $help ?? $name; + return $this->collectionRegistry->getOrRegisterGauge( + $this->namespace, + $name, + $help, + $labels + ); + } + + /** + * Registers or retrieves a histogram metric. + * + * @param string $name The name of the histogram. + * @param string|null $help The help text of the histogram. + * @param array $labels The labels of the histogram. + * @param array $buckets The buckets of the histogram. + * @return Histogram The registered or retrieved histogram. + */ + public function histogram(string $name, string $help = null, array $labels = [], array $buckets = [0.1, 1, 5, 10]): Histogram + { + $help = $help ?? $name; + return $this->collectionRegistry->getOrRegisterHistogram( + $this->namespace, + $name, + $help, + $labels, + $buckets + ); + } + + /** + * Sets a gauge metric to a specific value. + * + * @param string $name The name of the gauge. + * @param float $value The value to set the gauge to. + * @param array $labelValues The values of the labels for the gauge. + */ + public function setGauge(string $name, float $value, array $labelValues = []): void + { + $gauge = $this->collectionRegistry->getGauge($this->namespace, $name); + $gauge->set($value, $labelValues); + } + + /** + * Renders the metrics in the Prometheus text format. + * + * @return string The rendered metrics. + */ + public function renderMetrics(): string + { + $renderer = new RenderTextFormat(); + $metrics = $this->collectionRegistry->getMetricFamilySamples(); + return $renderer->render($metrics); + } +} diff --git a/ProcessMaker/Traits/ExtendedPMQL.php b/ProcessMaker/Traits/ExtendedPMQL.php index 69161bf708..6fcddf950d 100644 --- a/ProcessMaker/Traits/ExtendedPMQL.php +++ b/ProcessMaker/Traits/ExtendedPMQL.php @@ -43,7 +43,7 @@ public function useDataStoreTable(Builder $query, string $table, array $map) * PMQL scope that extends the standard PMQL scope by supporting any custom * aliases specified in the model. * - * @param \Illuminate\Database\Eloquent\Builder $builder + * @param Builder $builder * @param string $query * @param callable $callback * @@ -92,8 +92,8 @@ private static function getFromExpression($values, $fields) * Callback function to check for and handle any field aliases, value * aliases, or field wildcards specified in the given model. * - * @param \ProcessMaker\Query\Expression $expression - * @param \Illuminate\Database\Eloquent\Builder $builder + * @param Expression $expression + * @param Builder $builder * * @return mixed */ @@ -190,7 +190,7 @@ private function handle(Expression $expression, Builder $builder, User $user = n * Set the value as a string if possible. Also convert to the logged-in * user's timezone if the value is parsable by Carbon as a date. * - * @param \ProcessMaker\Query\Expression $expression + * @param Expression $expression * * @return mixed */ diff --git a/ProcessMaker/Traits/HasScreenFields.php b/ProcessMaker/Traits/HasScreenFields.php index 4fd7c9bf14..52c17f6cac 100644 --- a/ProcessMaker/Traits/HasScreenFields.php +++ b/ProcessMaker/Traits/HasScreenFields.php @@ -1,24 +1,27 @@ parsedFields)) { try { - $this->parsedFields = collect([]); - if ($this->config) { - $this->walkArray($this->config); - } + $this->loadScreenFields(); } catch (\Throwable $e) { Log::error("Error encountered while retrieving fields for screen #{$this->id}", [ 'message' => $e->getMessage(), @@ -31,9 +34,45 @@ public function getFieldsAttribute() ]); } } + return $this->parsedFields->unique('field'); } + /** + * Load the fields for the screen and cache them + * + * @return void + */ + private function loadScreenFields() + { + $screenCache = ScreenCacheFactory::getScreenCache(); + // Create cache key + $screenId = $this instanceof Screen ? $this->id : $this->screen_id; + $screenVersionId = $this instanceof Screen ? 0 : $this->id; + $key = $screenCache->createKey([ + 'process_id' => 0, + 'process_version_id' => 0, + 'language' => 'all', + 'screen_id' => (int) $screenId, + 'screen_version_id' => (int) $screenVersionId, + ]) . '_fields'; + + // Try to get the screen fields from cache + $parsedFields = $screenCache->get($key); + + if (!$parsedFields || collect($parsedFields)->isEmpty()) { + $this->parsedFields = ScreenCache::makeFrom($this, []); + if ($this->config) { + $this->walkArray($this->config); + } + $this->parsedFields = ScreenCache::makeFrom($this, $this->parsedFields); + + $screenCache->set($key, $this->parsedFields); + } else { + $this->parsedFields = ScreenCache::makeFrom($this, $parsedFields); + } + } + public function parseNestedScreen($node) { $nested = Screen::find($node['config']['screen']); @@ -56,7 +95,6 @@ public function walkArray($array, $key = null) $array = json_decode($array); } foreach ($array as $subkey => $value) { - if (isset($value['component']) && $value['component'] === 'FormNestedScreen') { $this->parseNestedScreen($value); } elseif (isset($value['component']) && $value['component'] === 'FormCollectionRecordControl') { @@ -132,6 +170,7 @@ public function parseItemFormat($item) break; } } + return $format; } @@ -159,7 +198,6 @@ public function parseEncryptedConfig($item) * * @return array */ - public function screenFilteredFields() { return $this->fields->pluck('field'); diff --git a/ProcessMaker/Traits/InteractsWithRawFilter.php b/ProcessMaker/Traits/InteractsWithRawFilter.php index ef92adec8a..9052f8b03b 100644 --- a/ProcessMaker/Traits/InteractsWithRawFilter.php +++ b/ProcessMaker/Traits/InteractsWithRawFilter.php @@ -2,9 +2,9 @@ namespace ProcessMaker\Traits; -use Illuminate\Support\Str; -use Illuminate\Support\Facades\DB; use Illuminate\Contracts\Database\Query\Expression; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; trait InteractsWithRawFilter { @@ -20,7 +20,7 @@ trait InteractsWithRawFilter /** * Unwrap the raw() and retrieve the string value passed * - * @return \Illuminate\Contracts\Database\Query\Expression + * @return Expression */ public function getRawValue(): Expression { @@ -113,7 +113,7 @@ private function validateOperator(): void $allowed = $this->validRawFilterOperators; if (!in_array($this->operator(), $allowed, true)) { - abort(422, 'Invalid operator: Only '.implode(', ', $allowed). ' are allowed.'); + abort(422, 'Invalid operator: Only ' . implode(', ', $allowed) . ' are allowed.'); } } } diff --git a/ProcessMaker/Traits/PluginServiceProviderTrait.php b/ProcessMaker/Traits/PluginServiceProviderTrait.php index b27e02482d..a26f9ab6cf 100644 --- a/ProcessMaker/Traits/PluginServiceProviderTrait.php +++ b/ProcessMaker/Traits/PluginServiceProviderTrait.php @@ -90,7 +90,7 @@ protected function completePluginBoot() /** * Executed during modeler starting * - * @param \ProcessMaker\Events\ModelerStarting $event + * @param ModelerStarting $event * * @throws \Exception */ @@ -217,7 +217,7 @@ public function removePackage($package) /** * Executed during script builder starting * - * @param \ProcessMaker\Events\ScriptBuilderStarting $event + * @param ScriptBuilderStarting $event * * @throws \Exception */ diff --git a/ProcessMaker/Traits/TaskScreenResourceTrait.php b/ProcessMaker/Traits/TaskScreenResourceTrait.php index bf0d97774e..23c245eb87 100644 --- a/ProcessMaker/Traits/TaskScreenResourceTrait.php +++ b/ProcessMaker/Traits/TaskScreenResourceTrait.php @@ -4,7 +4,6 @@ trait TaskScreenResourceTrait { - /** * Removes the inspector metadata from the screen configuration * @@ -16,6 +15,7 @@ private function removeInspectorMetadata(array $config) foreach ($config as $i => $page) { $config[$i]['items'] = $this->removeInspectorMetadataItems($page['items']); } + return $config; } @@ -40,6 +40,7 @@ private function removeInspectorMetadataItems(array $items) } $items[$i] = $item; } + return $items; } } diff --git a/README.md b/README.md index ceab37bb8d..e094ea0e60 100644 --- a/README.md +++ b/README.md @@ -439,11 +439,97 @@ npm run dev-font ``` -# Message broker driver, possible values: rabbitmq, kafka, this is optional, if not exists or is empty, the Nayra will be work as normally with local execution -MESSAGE_BROKER_DRIVER=rabbitmq +# Prometheus and Grafana + +This guide explains how to install and run **Prometheus** and **Grafana** using Docker. Both tools complement each other: Prometheus collects and monitors metrics, while Grafana visualizes them with interactive dashboards. + +## Local Development with docker compose +### Prometheus & Grafana +Go to the metrics directory +```text +cd metrics +``` + +Make sure the ports 9090 and 3000 on the host are not already in use. + +Edit `prometheus.yml` and update the target hostname with your local processmaker instance. You might also need to change the scheme if you are using https. + +Run `docker compose up -d` + +Check that prometheus can connect to your local instance at http://localhost:9090/targets + +Go to Grafana at http://localhost:3000/ + +When you are finished, run `docker compose down`. To delete all data, run `docker compose down -v` + +### **Use the Facade in Your Application** + +Now you can use the `Metrics` Facade anywhere in your application to manage metrics. + +### **1. Counter** +A **Counter** only **increases** over time or resets to zero. It is used for cumulative events. + +- Total number of HTTP requests: + ```php + $counter = Metrics::counter('http_requests_total', 'Total HTTP requests', ['method', 'status']); + $counter->inc(['GET', '200']); + $counter->incBy(2, ['GET', '200']); + ``` +- Number of system errors (e.g., HTTP 5xx). + +### **2. Gauge** +A **Gauge** can **increase or decrease**. It is used for values that fluctuate over time. + +- Current number of active jobs in a queue: + ```php + $gauge = Metrics::gauge('active_jobs', 'Number of active jobs', ['queue']); + $gauge->set(10, ['queue1']); + ``` +- Memory or CPU usage. + +### **3. Histogram** +A **Histogram** measures **value distributions** by organizing them into buckets. It is ideal for latency or size measurements. + +- Duration of HTTP requests: + ```php + $histogram = Metrics::histogram('http_request_duration_seconds', 'HTTP request duration', ['method'], [0.1, 0.5, 1, 5, 10]); + $histogram->observe(0.3, ['GET']); + ``` +- File sizes or request durations. + +Each type serves a specific role depending on the data being monitored. + +#### **Example: Incrementing a Counter** + +In a controller: + +```php +namespace App\Http\Controllers; + +use ProcessMaker\Facades\Metrics; + +class ExampleController extends Controller +{ + public function index() + { + //use metrics counter + $counter = Metrics::counter('http_requests_total', 'Total HTTP requests', ['method', 'status']); + $counter->inc(['GET', '200']); // Incrementa el contador para GET y estado 200. + + return response()->json(['message' => 'Hello, world!']); + } +} +``` + +To make things even easier, you can run `Metrics::counter('cases')->inc();` or `Metrics::gauge('active_tasks')->set($activeTasks)` anywhere in the code. + +You can provide an optional description, for example `Metrics::gauge('active_tasks', 'Total Active Tasks')->...` + +### Import Grafana Dashboards +Go to Grafana and import the dashboards from the `resources/grafana` folder. Each JSON file represents a configured dashboard that can be imported into Grafana to visualize metrics and data. -#### License +# License Distributed under the [AGPL Version 3](https://www.gnu.org/licenses/agpl-3.0.en.html) diff --git a/composer.json b/composer.json index 392efc6014..4b44f687a6 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,7 @@ "processmaker/laravel-i18next": "dev-master", "processmaker/nayra": "1.12.0", "processmaker/pmql": "1.12.1", + "promphp/prometheus_client_php": "^2.12", "psr/http-message": "^1.1", "psr/log": "^2.0", "psr/simple-cache": "^2.0", diff --git a/composer.lock b/composer.lock index da5618d1ac..5c013c19c8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4bda1b61a36be20aa74572dd7e477c38", + "content-hash": "6a12ea5fbcc6771ac3957652c9793e82", "packages": [ { "name": "aws/aws-crt-php", @@ -7786,6 +7786,74 @@ }, "time": "2023-12-08T03:35:17+00:00" }, + { + "name": "promphp/prometheus_client_php", + "version": "v2.12.0", + "source": { + "type": "git", + "url": "https://github.com/PromPHP/prometheus_client_php.git", + "reference": "50b70a6df4017081917e004f177a3c01cc8115db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PromPHP/prometheus_client_php/zipball/50b70a6df4017081917e004f177a3c01cc8115db", + "reference": "50b70a6df4017081917e004f177a3c01cc8115db", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.2|^8.0" + }, + "replace": { + "endclothing/prometheus_client_php": "*", + "jimdo/prometheus_client_php": "*", + "lkaemmerling/prometheus_client_php": "*" + }, + "require-dev": { + "guzzlehttp/guzzle": "^6.3|^7.0", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.5.4", + "phpstan/phpstan-phpunit": "^1.1.0", + "phpstan/phpstan-strict-rules": "^1.1.0", + "phpunit/phpunit": "^9.4", + "squizlabs/php_codesniffer": "^3.6", + "symfony/polyfill-apcu": "^1.6" + }, + "suggest": { + "ext-apc": "Required if using APCu.", + "ext-pdo": "Required if using PDO.", + "ext-redis": "Required if using Redis.", + "promphp/prometheus_push_gateway_php": "An easy client for using Prometheus PushGateway.", + "symfony/polyfill-apcu": "Required if you use APCu on PHP8.0+" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Prometheus\\": "src/Prometheus/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Lukas Kämmerling", + "email": "kontakt@lukas-kaemmerling.de" + } + ], + "description": "Prometheus instrumentation library for PHP applications.", + "support": { + "issues": "https://github.com/PromPHP/prometheus_client_php/issues", + "source": "https://github.com/PromPHP/prometheus_client_php/tree/v2.12.0" + }, + "time": "2024-10-18T07:33:22+00:00" + }, { "name": "psr/cache", "version": "3.0.0", diff --git a/config/app.php b/config/app.php index 4727a17ded..5cfccf5e9f 100644 --- a/config/app.php +++ b/config/app.php @@ -188,6 +188,7 @@ ProcessMaker\Providers\OauthMailServiceProvider::class, ProcessMaker\Providers\OpenAiServiceProvider::class, ProcessMaker\Providers\LicenseServiceProvider::class, + ProcessMaker\Providers\MetricsServiceProvider::class, ])->toArray(), 'aliases' => Facade::defaultAliases()->merge([ @@ -203,6 +204,7 @@ 'SkinManager' => ProcessMaker\Facades\SkinManager::class, 'Theme' => Igaster\LaravelTheme\Facades\Theme::class, 'WorkspaceManager' => ProcessMaker\Facades\WorkspaceManager::class, + 'SettingCache' => ProcessMaker\Cache\Settings\SettingCacheFacade::class, ])->toArray(), 'debug_blacklist' => [ @@ -267,6 +269,7 @@ 'vault_transit_key' => env('ENCRYPTED_DATA_VAULT_TRANSIT_KEY', ''), ], + 'prometheus_namespace' => env('PROMETHEUS_NAMESPACE', 'processmaker'), 'server_timing' => [ 'enabled' => env('SERVER_TIMING_ENABLED', true), 'min_package_time' => env('SERVER_TIMING_MIN_PACKAGE_TIME', 5), // Minimum time in milliseconds diff --git a/config/cache.php b/config/cache.php index 2c177975d7..4a1e0c2fda 100644 --- a/config/cache.php +++ b/config/cache.php @@ -99,6 +99,13 @@ 'driver' => 'octane', ], + 'cache_settings' => [ + 'driver' => 'redis', + 'connection' => 'cache_settings', + 'lock_connection' => 'cache_settings', + 'prefix' => env('CACHE_SETTING_PREFIX', 'settings'), + ], + ], /* diff --git a/config/database.php b/config/database.php index 2a3939c3b8..f50dc8bdab 100644 --- a/config/database.php +++ b/config/database.php @@ -181,6 +181,14 @@ 'database' => env('REDIS_CACHE_DB', '1'), ], + 'cache_settings' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_SETTING_DB', '2'), + ], ], ]; diff --git a/config/screens.php b/config/screens.php new file mode 100644 index 0000000000..10b838f944 --- /dev/null +++ b/config/screens.php @@ -0,0 +1,38 @@ + [ + // 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), + + // Cache metrics configuration + 'metrics' => [ + // Enable or disable cache metrics + 'enabled' => env('CACHE_METRICS_ENABLED', true), + + // Maximum number of timing samples to keep per key + 'max_samples' => env('CACHE_METRICS_MAX_SAMPLES', 100), + + // Redis prefix for metrics data + 'redis_prefix' => env('CACHE_METRICS_PREFIX', 'cache:metrics:'), + + // How long to keep metrics data (in seconds) + 'ttl' => env('CACHE_METRICS_TTL', 86400), // 24 hours + ], + ], +]; diff --git a/database/migrations/2025_01_13_212522_add_timestamps_ms_to_process_request_tokens_table.php b/database/migrations/2025_01_13_212522_add_timestamps_ms_to_process_request_tokens_table.php new file mode 100644 index 0000000000..8fded45da5 --- /dev/null +++ b/database/migrations/2025_01_13_212522_add_timestamps_ms_to_process_request_tokens_table.php @@ -0,0 +1,30 @@ +bigInteger('created_at_ms')->nullable(); + $table->bigInteger('completed_at_ms')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('process_request_tokens', function (Blueprint $table) { + $table->dropColumn('created_at_ms'); + $table->dropColumn('completed_at_ms'); + }); + } +}; diff --git a/docs/cache-monitoring.md b/docs/cache-monitoring.md new file mode 100644 index 0000000000..5626038aeb --- /dev/null +++ b/docs/cache-monitoring.md @@ -0,0 +1,77 @@ +# ProcessMaker Cache Monitoring System + +## Overview +The ProcessMaker Cache Monitoring System is a comprehensive solution for tracking and analyzing cache performance metrics in real-time. It uses Redis as a backend store for metrics and provides detailed insights into cache usage patterns, hit/miss rates, and memory consumption. + +## Current Implementation + +### Architecture +1. **Core Components**: + - `CacheMetricsInterface`: Defines the contract for metrics collection + - `PrometheusMetricsManager`: Implements metrics storage using Redis + - `CacheMetricsDecorator`: Wraps cache implementations to collect metrics + +2. **Key Features**: + - Real-time metrics collection + - Hit/miss rate tracking + - Response time monitoring + - Memory usage tracking + - Top keys analysis + - Performance insights + +3. **Storage Strategy**: + - Uses Redis hash structures for counters + - Maintains rolling lists for timing data + - Prefix-based key organization + - Automatic cleanup of old data + +### Metrics Collected +- Cache hits and misses +- Response times for hits/misses +- Memory usage per key +- Last write timestamps +- Access patterns +- Overall performance statistics + + +## Why Redis? +The current Redis-based implementation was chosen because: +1. **Performance**: Redis provides fast read/write operations +2. **Data Structures**: Native support for counters and lists +3. **TTL Support**: Automatic expiration of old metrics +4. **Scalability**: Easy to scale horizontally +5. **Integration**: Already used in ProcessMaker's infrastructure + +## Usage Examples + +### Basic Monitoring +```php +// Get hit rate for a specific key +$hitRate = $metrics->getHitRate('my_cache_key'); + +// Get memory usage +$usage = $metrics->getMemoryUsage('my_cache_key'); + +// Get performance summary +$summary = $metrics->getSummary(); +``` + +### Performance Analysis +```php +// Get top accessed keys +$topKeys = $metrics->getTopKeys(10); + +// Analyze response times +$avgHitTime = $metrics->getHitAvgTime('my_cache_key'); +$avgMissTime = $metrics->getMissAvgTime('my_cache_key'); +``` + +## Future Improvements +1. **Aggregation**: Add support for metric aggregation by time periods +2. **Sampling**: Implement sampling for high-traffic scenarios +3. **Alerts**: Add threshold-based alerting system +4. **Visualization**: Integrate with monitoring dashboards +5. **Custom Metrics**: Allow adding custom metrics per cache type + +## Conclusion +The Redis-based monitoring system provides a good balance between performance, functionality, and maintainability. While there are alternatives available, the current implementation meets ProcessMaker's requirements for real-time cache monitoring with minimal overhead. \ No newline at end of file diff --git a/metrics/compose.yaml b/metrics/compose.yaml new file mode 100644 index 0000000000..27cea568d2 --- /dev/null +++ b/metrics/compose.yaml @@ -0,0 +1,28 @@ +services: + prometheus: + image: prom/prometheus + container_name: prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + ports: + - 9090:9090 + restart: unless-stopped + volumes: + - ./prometheus:/etc/prometheus + - prom_data:/prometheus + # This allows Docker to resolve your Herd domain + # extra_hosts: + # - "processmaker.test:host-gateway" + grafana: + image: grafana/grafana + container_name: grafana + ports: + - 3000:3000 + restart: unless-stopped + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + volumes: + - ./grafana:/etc/grafana/provisioning/datasources +volumes: + prom_data: diff --git a/metrics/grafana/datasource.yml b/metrics/grafana/datasource.yml new file mode 100644 index 0000000000..cbde3cccf6 --- /dev/null +++ b/metrics/grafana/datasource.yml @@ -0,0 +1,11 @@ +# Global configuration settings for Grafana +apiVersion: 1 + +# Grafana datasource configuration for Prometheus +datasources: +- name: Prometheus + type: prometheus + url: http://prometheus:9090 + isDefault: true + access: proxy + editable: true diff --git a/metrics/prometheus/prometheus.yml b/metrics/prometheus/prometheus.yml new file mode 100644 index 0000000000..786adcf9a8 --- /dev/null +++ b/metrics/prometheus/prometheus.yml @@ -0,0 +1,26 @@ +global: + scrape_interval: 15s + scrape_timeout: 10s + evaluation_interval: 15s + +scrape_configs: + - job_name: processmaker + honor_timestamps: true + scrape_interval: 15s + scrape_timeout: 10s + metrics_path: /metrics + scheme: http + static_configs: + - targets: + # Replace this with your local processmaker instance (add port if needed) + - processmaker.test + # This allows Docker to resolve your Herd domain + # - "host.docker.internal:80" + # This allows Docker to resolve your Herd domain + # relabel_configs: + # - source_labels: [__address__] + # target_label: __param_target + # - source_labels: [__param_target] + # target_label: instance + # - target_label: __address__ + # replacement: processmaker.test diff --git a/resources/grafana/MainDashboard.json b/resources/grafana/MainDashboard.json new file mode 100644 index 0000000000..1e367abd99 --- /dev/null +++ b/resources/grafana/MainDashboard.json @@ -0,0 +1,816 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS-PM-SPRING-2025", + "label": "prometheus-pm-spring-2025", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "panel", + "id": "barchart", + "name": "Bar chart", + "version": "" + }, + { + "type": "panel", + "id": "bargauge", + "name": "Bar gauge", + "version": "" + }, + { + "type": "panel", + "id": "gauge", + "name": "Gauge", + "version": "" + }, + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "11.4.0" + }, + { + "type": "panel", + "id": "piechart", + "name": "Pie chart", + "version": "" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Different metrics from ProcessMaker", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "description": "Visual representation of cache performance showing the hit and miss rates for screens.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "displayMode": "gradient", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "disableTextWrap": false, + "editorMode": "code", + "expr": "max(processmaker_cache_hits_total{cache_key=~\"pid_.*\"})", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Hits", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "disableTextWrap": false, + "editorMode": "code", + "expr": "max(processmaker_cache_misses_total{cache_key=~\"pid_.*\"})", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "Misses", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "Screen Cache Hit/Miss Rates", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "description": "Visual representation of cache performance showing the hit and miss rates for settings.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "displayMode": "gradient", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "disableTextWrap": false, + "editorMode": "code", + "expr": "max(processmaker_cache_hits_total{cache_key=~\"setting_.*\"})", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Hits", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "disableTextWrap": false, + "editorMode": "code", + "expr": "max(processmaker_cache_misses_total{cache_key=~\"setting_.*\"})", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "Misses", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "Settings Cache Hit/Miss Rates", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "description": "Visual representation of cache performance showing the hit and miss rates for screens.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "disableTextWrap": false, + "editorMode": "code", + "expr": "topk(5, processmaker_cache_memory_bytes{label=~\"screen.*\"})", + "fullMetaSearch": false, + "includeNullMetadata": true, + "interval": "", + "legendFormat": "{{label}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Screen Cache Sizes", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "description": "Visual representation of cache performance showing the hit and miss rates for screens.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "interval": "30m", + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum(processmaker_activity_completed_total)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Tasks completed", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Tasks Completed", + "type": "barchart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "description": "Time a task was completed", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "fieldMinMax": false, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 6, + "interval": "15s", + "options": { + "displayMode": "gradient", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "avg without(activity_id) (processmaker_activity_execution_time_seconds_sum{element_type=\"task\"})", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{activity_name}} (process={{process_id}})", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Task Execution Time", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "description": "Top Slowest Tasks", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "fieldMinMax": false, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 5, + "interval": "15s", + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "topk(10, avg(processmaker_activity_execution_time_seconds_sum{element_type=\"task\"}) by (process_id, activity_name))", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{activity_name}} (process={{process_id}})", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Top Slowest Tasks", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "description": "Shows task completion distribution by activity and process to highlight execution patterns and identify key task volumes.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "fieldMinMax": false, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 8, + "interval": "30m", + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "sum(processmaker_activity_completed_total) by (activity_name, process_id)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{activity_name}} (process={{process_id}})", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Completion Count by Task and Process", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "description": "Average task time completition", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 6, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineStyle": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + }, + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 7, + "interval": "15s", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-PM-SPRING-2025}" + }, + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "avg(processmaker_activity_execution_time_seconds_sum{element_type=\"task\"})", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "Average task time completion", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Average Task Completion Time chart", + "type": "timeseries" + } + ], + "schemaVersion": 40, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "ProcessMaker Dashboard", + "uid": "be96wxsnlmn7kc", + "version": 38, + "weekStart": "" +} \ No newline at end of file diff --git a/resources/grafana/README.md b/resources/grafana/README.md new file mode 100644 index 0000000000..321b955331 --- /dev/null +++ b/resources/grafana/README.md @@ -0,0 +1,44 @@ +# Grafana Dashboards + +This folder contains exported JSON files of Grafana dashboards. Each JSON file represents a configured dashboard that can be imported into Grafana to visualize metrics and data. + +## 📁 Folder Contents + +- **Dashboard JSON Files**: These files contain the configuration of various Grafana dashboards. Each file is a snapshot of a Grafana dashboard, including its panels, data sources, and visualizations. +- Example: `MainDashboard.json` – A JSON file for monitoring cache hit/miss rates. + +--- + +## 🔄 How to Import a Dashboard to Grafana + +Follow these steps to import any of the dashboards from this folder into your Grafana instance: + +### 1. Open Grafana +- Log in to your Grafana instance. + +### 2. Go to Import Dashboard +- From the left-hand menu, select **Dashboards**. +- On the top right, click **+ Plus button** > **Import Dashboard**. + +### 3. Upload the JSON File +- **Option 1**: Click **Upload JSON file** and select the desired `.json` file from this folder. +- **Option 2**: Open the JSON file in a text editor, copy its content, and paste it into the **Import via panel JSON** section. + +### 4. Configure Data Source +- If the dashboard relies on specific data sources (e.g., Prometheus), you may need to reassign them during the import process. + - Example: The exported JSON might reference a data source like `prometheus-pm-spring-2025`. Ensure you have a compatible data source configured in Grafana. + +### 5. Save and View the Dashboard +- Once imported, you can save and view the dashboard in your Grafana instance. + +--- + +## ⚙️ Customization +After importing a dashboard, you can: +- Update queries to match your metrics. +- Adjust visualization settings to fit your requirements. +- Modify the layout or add/remove panels. + +--- + +Enjoy visualizing your metrics! 🚀 \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index e9240fc6b3..9808c4fdf6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ only('index'); + +// Metrics Route +Route::get('/metrics', function () { + return response(Metrics::renderMetrics(), 200, [ + 'Content-Type' => 'text/plain; version=0.0.4', + ]); +}); \ No newline at end of file diff --git a/tests/Feature/Api/SettingAuthTest.php b/tests/Feature/Api/SettingAuthTest.php new file mode 100644 index 0000000000..4736f8d240 --- /dev/null +++ b/tests/Feature/Api/SettingAuthTest.php @@ -0,0 +1,281 @@ +create([ + 'title' => 'Node Executor', + 'description' => 'Default Javascript/Node Executor', + 'language' => 'javascript', + ]); + + ProcessCategory::factory()->create([ + 'name' => 'System', + 'status' => 'ACTIVE', + 'is_system' => true, + ]); + + \Artisan::call('db:seed', ['--class' => LdapSeeder::class, '--force' => true]); + } + + public function testDefaultLdapSettings() + { + $this->seedLDAPSettings(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'LDAP', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $response->assertStatus(200); + $this->assertCount(18, $response['data']); + + $this->assertDatabaseCount('settings', 38); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.enabled', 'name' => 'Enabled', 'format' => 'boolean']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.cron.period', 'name' => 'Synchronization Schedule', 'format' => 'object']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.type', 'name' => 'Type', 'format' => 'choice']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.server.address', 'name' => 'Server Address', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.server.port', 'name' => 'Server Port', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.server.tls', 'name' => 'TLS', 'format' => 'boolean']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.base_dn', 'name' => 'Base DN', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.authentication.username', 'name' => 'Username', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.authentication.password', 'name' => 'Password', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.groups', 'name' => 'Groups To Import', 'format' => 'checkboxes']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.departments', 'name' => 'Departments To Import', 'format' => 'checkboxes']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.identifiers.user', 'name' => 'User Identifier', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.identifiers.group', 'name' => 'Group Identifier', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.identifiers.user_class', 'name' => 'User Class Identifier', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.identifiers.group_class', 'name' => 'Group Class Identifier', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.variables', 'name' => 'Variable Map', 'format' => 'object']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.users.chunksize', 'name' => 'Chunk Size for User Import', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.log', 'name' => 'Logs', 'format' => 'button']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.certificate_file', 'name' => 'Certificate location', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.ldap.certificate', 'name' => 'Certificate', 'format' => 'file']); + + $this->assertDatabaseCount('security_logs', 0); + } + + public function testUpdateLdapSettings() + { + $this->seedLDAPSettings(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'LDAP', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(18, $response['data']); + + $enabled = $response['data'][0]; + $this->assertEquals('Enabled', $enabled['name']); + $this->assertEquals(0, $enabled['config']); + + $syncSchedule = $response['data'][1]; + $this->assertEquals('Synchronization Schedule', $syncSchedule['name']); + $this->assertEquals(['quantity' => 1, "units" => "days"], $syncSchedule['config']); + + $type = $response['data'][2]; + $this->assertEquals('Type', $type['name']); + $this->assertNull($type['config']); + + $serverAddress = $response['data'][3]; + $this->assertEquals('Server Address', $serverAddress['name']); + $this->assertNull($serverAddress['config']); + + $serverPort = $response['data'][4]; + $this->assertEquals('Server Port', $serverPort['name']); + $this->assertEquals(636, $serverPort['config']); + + $tls = $response['data'][5]; + $this->assertEquals('TLS', $tls['name']); + $this->assertEquals(1, $tls['config']); + + $username = $response['data'][8]; + $this->assertEquals('Username', $username['name']); + $this->assertNull($username['config']); + + $password = $response['data'][9]; + $this->assertEquals('Password', $password['name']); + $this->assertNull($password['config']); + + $data = array_merge($enabled, ['config' => 1]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $enabled['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $enabled['id'], 'config' => 1]); + + $data = array_merge($syncSchedule, ['config' => ['quantity' => 2, "units" => "hours"]]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $syncSchedule['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $syncSchedule['id'], 'config' => json_encode(['quantity' => 2, "units" => "hours"])]); + + $data = array_merge($type, ['config' => 'ad']); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $type['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $type['id'], 'config' => 'ad']); + + $data = array_merge($type, ['config' => '389ds']); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $type['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $type['id'], 'config' => '389ds']); + + $data = array_merge($type, ['config' => 'openldap']); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $type['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $type['id'], 'config' => 'openldap']); + + $data = array_merge($serverAddress, ['config' => 'ldap://ldap.example.com']); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $serverAddress['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $serverAddress['id'], 'config' => 'ldap://ldap.example.com']); + + $data = array_merge($serverPort, ['config' => 389]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $serverPort['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $serverPort['id'], 'config' => 389]); + + $data = array_merge($tls, ['config' => 0]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $tls['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $tls['id'], 'config' => 0]); + + $data = array_merge($username, ['config' => 'admin']); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $username['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $username['id'], 'config' => 'admin']); + + $data = array_merge($password, ['config' => 'password']); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $password['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $password['id'], 'config' => 'password']); + + $this->assertDatabaseCount('security_logs', 10); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $enabled['id']]); + } + + public function testDefaultSsoSettings() + { + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'SSO', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $response->assertStatus(200); + $this->assertCount(0, $response['data']); + + \Artisan::call('db:seed', ['--class' => AuthSeeder::class, '--force' => true]); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'SSO', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $response->assertStatus(200); + $this->assertCount(4, $response['data']); + + $this->assertDatabaseCount('settings', 23); + $this->assertDatabaseHas('settings', ['key' => 'standard-login.enabled', 'name' => 'Allow Standard Login', 'format' => 'boolean']); + $this->assertDatabaseHas('settings', ['key' => 'sso.automatic_user_creation', 'name' => 'Automatic Registration', 'format' => 'boolean']); + $this->assertDatabaseHas('settings', ['key' => 'sso.user_default_config', 'name' => 'New User Default Config', 'format' => 'object']); + $this->assertDatabaseHas('settings', ['key' => 'sso.debug', 'name' => 'Debug Mode', 'format' => 'boolean']); + $this->assertDatabaseHas('settings', ['key' => 'package.auth.installed']); + + \Artisan::call('db:seed', ['--class' => AtlassianSeeder::class, '--force' => true]); + \Artisan::call('db:seed', ['--class' => Auth0Seeder::class, '--force' => true]); + \Artisan::call('db:seed', ['--class' => FacebookSeeder::class, '--force' => true]); + \Artisan::call('db:seed', ['--class' => GitHubSeeder::class, '--force' => true]); + \Artisan::call('db:seed', ['--class' => GoogleSeeder::class, '--force' => true]); + \Artisan::call('db:seed', ['--class' => KeycloakSeeder::class, '--force' => true]); + \Artisan::call('db:seed', ['--class' => MicrosoftSeeder::class, '--force' => true]); + \Artisan::call('db:seed', ['--class' => SamlSeeder::class, '--force' => true]); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'SSO', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $response->assertStatus(200); + $this->assertCount(12, $response['data']); + $this->assertDatabaseCount('settings', 69); + + $this->assertDatabaseHas('settings', ['key' => 'services.atlassian.client_id', 'name' => 'Client ID', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.atlassian.client_secret', 'name' => 'Client Secret', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.atlassian.redirect', 'name' => 'Redirect', 'format' => 'text']); + + $this->assertDatabaseHas('settings', ['key' => 'services.auth0.client_id', 'name' => 'Client ID', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.auth0.redirect', 'name' => 'Callback URL', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.auth0.client_secret', 'name' => 'Client Secret', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.auth0.base_url', 'name' => 'Domain', 'format' => 'text']); + + $this->assertDatabaseHas('settings', ['key' => 'services.facebook.client_id', 'name' => 'App ID', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.facebook.client_secret', 'name' => 'App Secret', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.facebook.redirect', 'name' => 'Redirect', 'format' => 'text']); + + $this->assertDatabaseHas('settings', ['key' => 'services.github.client_id', 'name' => 'Client ID', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.github.redirect', 'name' => 'Redirect', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.github.client_secret', 'name' => 'Client Secret', 'format' => 'text']); + + $this->assertDatabaseHas('settings', ['key' => 'services.google.redirect', 'name' => 'Redirect', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.google.client_id', 'name' => 'Client ID', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.google.client_secret', 'name' => 'Client Secret', 'format' => 'text']); + + $this->assertDatabaseHas('settings', ['key' => 'services.keycloak.base_url', 'name' => 'Base URL', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.keycloak.client_secret', 'name' => 'Client Secret', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.keycloak.realms', 'name' => 'Realm', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.keycloak.client_id', 'name' => 'Client ID', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.keycloak.redirect', 'name' => 'Redirect', 'format' => 'text']); + + $this->assertDatabaseHas('settings', ['key' => 'services.microsoft.redirect', 'name' => 'Redirect', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.microsoft.client_id', 'name' => 'Client ID', 'format' => 'text']); + $this->assertDatabaseHas('settings', ['key' => 'services.microsoft.client_secret', 'name' => 'Client Secret', 'format' => 'text']); + + $this->assertDatabaseCount('security_logs', 0); + } + + public function testUpdateSsoSettings() + { + \Artisan::call('db:seed', ['--class' => AuthSeeder::class, '--force' => true]); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'SSO', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(4, $response['data']); + + $allowStandardLogin = $response['data'][0]; + $this->assertEquals('Allow Standard Login', $allowStandardLogin['name']); + $this->assertEquals(1, $allowStandardLogin['config']); + + $automaticRegistration = $response['data'][1]; + $this->assertEquals('Automatic Registration', $automaticRegistration['name']); + $this->assertEquals(1, $automaticRegistration['config']); + + $newUserDefaultConfig = $response['data'][2]; + $this->assertEquals('New User Default Config', $newUserDefaultConfig['name']); + $this->assertEquals(['permissions' => [], 'groups' => []], $newUserDefaultConfig['config']); + + $debugMode = $response['data'][3]; + $this->assertEquals('Debug Mode', $debugMode['name']); + $this->assertEquals(0, $debugMode['config']); + + $data = array_merge($allowStandardLogin, ['config' => 1]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $allowStandardLogin['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $allowStandardLogin['id'], 'config' => 1]); + + $data = array_merge($automaticRegistration, ['config' => 0]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $automaticRegistration['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $automaticRegistration['id'], 'config' => 0]); + + $data = array_merge($newUserDefaultConfig, ['config' => ['permissions' => ['view', 'edit'], 'groups' => ['admin', 'user']]]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $newUserDefaultConfig['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $newUserDefaultConfig['id'], 'config' => json_encode(['permissions' => ['view', 'edit'], 'groups' => ['admin', 'user']])]); + + $data = array_merge($debugMode, ['config' => 1]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $debugMode['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $debugMode['id'], 'config' => 1]); + + $this->assertDatabaseCount('security_logs', 4); + } +} diff --git a/tests/Feature/Api/SettingLogInOptionsTest.php b/tests/Feature/Api/SettingLogInOptionsTest.php new file mode 100644 index 0000000000..ea6ce3a9d7 --- /dev/null +++ b/tests/Feature/Api/SettingLogInOptionsTest.php @@ -0,0 +1,252 @@ +artisan('migrate', [ + '--path' => 'upgrades/2023_11_30_185738_add_password_policies_settings.php', + ])->run(); + } + + public function testDefaultLogInOptionsSettings() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Log-In Options', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $response->assertStatus(200); + $this->assertCount(10, $response['data']); + $response->assertJsonFragment(['name' => 'Password set by user', 'key' => 'password-policies.users_can_change', 'format' => 'boolean']); + $response->assertJsonFragment(['name' => 'Numeric characters', 'key' => 'password-policies.numbers', 'format' => 'boolean']); + $response->assertJsonFragment(['name' => 'Uppercase characters', 'key' => 'password-policies.uppercase', 'format' => 'boolean']); + $response->assertJsonFragment(['name' => 'Special characters', 'key' => 'password-policies.special', 'format' => 'boolean']); + $response->assertJsonFragment(['name' => 'Maximum length', 'key' => 'password-policies.maximum_length', 'format' => 'text']); + $response->assertJsonFragment(['name' => 'Minimum length', 'key' => 'password-policies.minimum_length', 'format' => 'text']); + $response->assertJsonFragment(['name' => 'Password expiration', 'key' => 'password-policies.expiration_days', 'format' => 'text']); + $response->assertJsonFragment(['name' => 'Login failed', 'key' => 'password-policies.login_attempts', 'format' => 'text']); + $response->assertJsonFragment(['name' => 'Require Two Step Authentication', 'key' => 'password-policies.2fa_enabled', 'format' => 'boolean']); + $response->assertJsonFragment(['name' => 'Two Step Authentication Method', 'key' => 'password-policies.2fa_method', 'format' => 'checkboxes']); + + $this->assertDatabaseCount('security_logs', 0); + } + + public function testUpdatePasswordSetByUserSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Log-In Options', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(10, $response['data']); + $passwordSetByUser = $response['data'][0]; + $this->assertEquals('Password set by user', $passwordSetByUser['name']); + $this->assertEquals(true, $passwordSetByUser['config']); + + $data = array_merge($passwordSetByUser, ['config' => false]); + + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $passwordSetByUser['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $passwordSetByUser['id'], 'config' => false]); + + $this->assertDatabaseCount('security_logs', 1); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $passwordSetByUser['id']]); + } + + public function testUpdateNumericCharactersSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Log-In Options', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(10, $response['data']); + $numericCharacters = $response['data'][1]; + $this->assertEquals('Numeric characters', $numericCharacters['name']); + $this->assertEquals(true, $numericCharacters['config']); + + $data = array_merge($numericCharacters, ['config' => false]); + + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $numericCharacters['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $numericCharacters['id'], 'config' => false]); + + $this->assertDatabaseCount('security_logs', 1); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $numericCharacters['id']]); + } + + public function testUpdateUppercaseCharactersSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Log-In Options', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(10, $response['data']); + $uppercaseCharacters = $response['data'][2]; + $this->assertEquals('Uppercase characters', $uppercaseCharacters['name']); + $this->assertEquals(true, $uppercaseCharacters['config']); + + $data = array_merge($uppercaseCharacters, ['config' => false]); + + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $uppercaseCharacters['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $uppercaseCharacters['id'], 'config' => false]); + + $this->assertDatabaseCount('security_logs', 1); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $uppercaseCharacters['id']]); + } + + public function testUpdateSpecialCharactersSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Log-In Options', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(10, $response['data']); + $specialCharacters = $response['data'][3]; + $this->assertEquals('Special characters', $specialCharacters['name']); + $this->assertEquals(true, $specialCharacters['config']); + + $data = array_merge($specialCharacters, ['config' => false]); + + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $specialCharacters['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $specialCharacters['id'], 'config' => false]); + + $this->assertDatabaseCount('security_logs', 1); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $specialCharacters['id']]); + } + + public function testUpdateMaximumLengthSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Log-In Options', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(10, $response['data']); + $maximumLength = $response['data'][4]; + $this->assertEquals('Maximum length', $maximumLength['name']); + $this->assertNull($maximumLength['config']); + + $data = array_merge($maximumLength, ['config' => '64']); + + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $maximumLength['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $maximumLength['id'], 'config' => '64']); + + $this->assertDatabaseCount('security_logs', 1); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $maximumLength['id']]); + } + + public function testUpdateMinimumLengthSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Log-In Options', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(10, $response['data']); + $minimumLength = $response['data'][5]; + $this->assertEquals('Minimum length', $minimumLength['name']); + $this->assertEquals(8, $minimumLength['config']); + + $data = array_merge($minimumLength, ['config' => '10']); + + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $minimumLength['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $minimumLength['id'], 'config' => '10']); + + $this->assertDatabaseCount('security_logs', 1); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $minimumLength['id']]); + } + + public function testUpdatePasswordExpirationSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Log-In Options', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(10, $response['data']); + $passwordExpiration = $response['data'][6]; + $this->assertEquals('Password expiration', $passwordExpiration['name']); + $this->assertNull($passwordExpiration['config']); + + $data = array_merge($passwordExpiration, ['config' => '30']); + + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $passwordExpiration['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $passwordExpiration['id'], 'config' => '30']); + + $this->assertDatabaseCount('security_logs', 1); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $passwordExpiration['id']]); + } + + public function testUpdateLoginFailedSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Log-In Options', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(10, $response['data']); + $loginFailed = $response['data'][7]; + $this->assertEquals('Login failed', $loginFailed['name']); + $this->assertEquals(5, $loginFailed['config']); + + $data = array_merge($loginFailed, ['config' => '3']); + + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $loginFailed['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $loginFailed['id'], 'config' => '3']); + + $this->assertDatabaseCount('security_logs', 1); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $loginFailed['id']]); + } + + public function testUpdateRequireTwoStepAuthenticationSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Log-In Options', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(10, $response['data']); + $requireTwoStepAuthentication = $response['data'][8]; + $this->assertEquals('Require Two Step Authentication', $requireTwoStepAuthentication['name']); + $this->assertEquals(false, $requireTwoStepAuthentication['config']); + + $data = array_merge($requireTwoStepAuthentication, ['config' => true]); + + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $requireTwoStepAuthentication['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $requireTwoStepAuthentication['id'], 'config' => true]); + + $this->assertDatabaseCount('security_logs', 1); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $requireTwoStepAuthentication['id']]); + } + + public function testUpdateTwoStepAuthenticationMethodSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Log-In Options', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(10, $response['data']); + $twoStepAuthenticationMethod = $response['data'][9]; + $this->assertEquals('Two Step Authentication Method', $twoStepAuthenticationMethod['name']); + $this->assertEquals([], $twoStepAuthenticationMethod['config']); + + $data = array_merge($twoStepAuthenticationMethod, ['config' => [['By email']]]); + + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $twoStepAuthenticationMethod['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $twoStepAuthenticationMethod['id'], 'config' => json_encode([['By email']])]); + + $data = array_merge($twoStepAuthenticationMethod, ['config' => [['By message to phone number']]]); + + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $twoStepAuthenticationMethod['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $twoStepAuthenticationMethod['id'], 'config' => json_encode([['By message to phone number']])]); + + $data = array_merge($twoStepAuthenticationMethod, ['config' => [['Authenticator App']]]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $twoStepAuthenticationMethod['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $twoStepAuthenticationMethod['id'], 'config' => json_encode([['Authenticator App']])]); + + $this->assertDatabaseCount('security_logs', 3); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $twoStepAuthenticationMethod['id']]); + } +} diff --git a/tests/Feature/Api/SettingSessionControlTest.php b/tests/Feature/Api/SettingSessionControlTest.php new file mode 100644 index 0000000000..dbed80b64d --- /dev/null +++ b/tests/Feature/Api/SettingSessionControlTest.php @@ -0,0 +1,112 @@ +artisan('migrate', [ + '--path' => 'upgrades/2023_12_06_182508_add_session_control_settings.php', + ])->run(); + } + + public function testDefaultSessionControlSettings() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Session Control', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $response->assertStatus(200); + $this->assertCount(3, $response['data']); + $response->assertJsonFragment(['name' => 'IP restriction', 'key' => 'session-control.ip_restriction', 'format' => 'choice']); + $response->assertJsonFragment(['name' => 'Device restriction', 'key' => 'session-control.device_restriction', 'format' => 'choice']); + $response->assertJsonFragment(['name' => 'Session Inactivity', 'key' => 'session.lifetime', 'format' => 'text']); + + $this->assertDatabaseCount('settings', 21); + $this->assertDatabaseCount('security_logs', 0); + } + + public function testUpdateIPRestrictionSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Session Control', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(3, $response['data']); + $ipRestriction = $response['data'][0]; + $this->assertEquals('IP restriction', $ipRestriction['name']); + $this->assertEquals(0, $ipRestriction['config']); + + $data = array_merge($ipRestriction, ['config' => 1]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $ipRestriction['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $ipRestriction['id'], 'config' => 1]); + + $data = array_merge($ipRestriction, ['config' => 2]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $ipRestriction['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $ipRestriction['id'], 'config' => 2]); + + $data = array_merge($ipRestriction, ['config' => 0]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $ipRestriction['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $ipRestriction['id'], 'config' => 0]); + + $this->assertDatabaseCount('security_logs', 3); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $ipRestriction['id']]); + } + + public function testUpdateDeviceRestrictionSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Session Control', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(3, $response['data']); + $deviceRestriction = $response['data'][1]; + $this->assertEquals('Device restriction', $deviceRestriction['name']); + $this->assertEquals(0, $deviceRestriction['config']); + + $data = array_merge($deviceRestriction, ['config' => 1]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $deviceRestriction['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $deviceRestriction['id'], 'config' => 1]); + + $data = array_merge($deviceRestriction, ['config' => 2]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $deviceRestriction['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $deviceRestriction['id'], 'config' => 2]); + + $data = array_merge($deviceRestriction, ['config' => 0]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $deviceRestriction['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $deviceRestriction['id'], 'config' => 0]); + + $this->assertDatabaseCount('security_logs', 3); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $deviceRestriction['id']]); + } + + public function testUpdateSessionLifetimeSetting() + { + $this->upgrade(); + + $response = $this->apiCall('GET', route('api.settings.index', ['group' => 'Session Control', 'order_by' => 'name', 'order_direction' => 'ASC'])); + $this->assertCount(3, $response['data']); + $sessionLifetime = $response['data'][2]; + $this->assertEquals('Session Inactivity', $sessionLifetime['name']); + $this->assertEquals(120, $sessionLifetime['config']); + + $data = array_merge($sessionLifetime, ['config' => 30]); + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $sessionLifetime['id']]), $data); + $response->assertStatus(204); + $this->assertDatabaseHas('settings', ['id' => $sessionLifetime['id'], 'config' => 30]); + + $this->assertDatabaseCount('security_logs', 1); + $this->assertDatabaseHas('security_logs', ['event' => 'SettingsUpdated', 'changes->setting_id' => $sessionLifetime['id']]); + } +} diff --git a/tests/Feature/Api/V1_1/TaskControllerTest.php b/tests/Feature/Api/V1_1/TaskControllerTest.php index 67f87100d2..9032362162 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() { @@ -32,16 +40,47 @@ public function testShowScreen() __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, ]); + $processVersion = $process->getPublishedVersion([]); $task = ProcessRequestToken::factory()->create([ 'element_type' => 'task', 'element_name' => 'Task 1', 'element_id' => 'node_2', - 'process_id' => Process::where('name', 'nested screen test')->first()->id, + 'process_id' => $process->id, 'process_request_id' => $request->id, ]); + $screenVersion = $task->getScreenVersion(); + $this->assertNotNull($screenVersion, 'Screen version not found'); + + // 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 + $expectedParams = [ + 'process_id' => (int) $process->id, + 'process_version_id' => (int) $processVersion->id, + 'language' => $this->user->language, + 'screen_id' => (int) $screenVersion->screen_id, + 'screen_version_id' => (int) $screenVersion->id, + ]; + + // Mock createKey method with array parameter + $screenCache->expects($this->once()) + ->method('createKey') + ->with($expectedParams) + ->willReturn("pid_{$process->id}_{$processVersion->id}_{$this->user->language}_sid_{$screenVersion->screen_id}_{$screenVersion->id}"); + $response = $this->apiCall('GET', route('api.1.1.tasks.show.screen', $task->id) . '?include=screen,nested'); $this->assertNotNull($response->json()); $this->assertIsArray($response->json()); @@ -51,14 +90,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 +109,68 @@ 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); - $processId = $process->id; - $processVersionId = $processVersion->id; - $language = $this->user->language; - $screenId = $screenVersion->screen_id; - $screenVersionId = $screenVersion->id; - - // Get the screen cache key - $screenKey = ScreenCompiledManager::createKey( - $processId, - $processVersionId, - $language, - $screenId, - $screenVersionId - ); - // 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() + // 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 + $expectedParams = [ + 'process_id' => (int) $process->id, + 'process_version_id' => (int) $processVersion->id, + 'language' => $this->user->language, + 'screen_id' => (int) $screenVersion->screen_id, + 'screen_version_id' => (int) $screenVersion->id, + ]; + + $screenKey = "pid_{$process->id}_{$processVersion->id}_{$this->user->language}_sid_{$screenVersion->screen_id}_{$screenVersion->id}"; + + // Mock createKey method + $screenCache->expects($this->once()) + ->method('createKey') + ->with($expectedParams) + ->willReturn($screenKey); + + // Mock cached content + $cachedContent = [ + 'id' => $screenVersion->screen_id, + 'screen_version_id' => $screenVersion->id, + 'config' => ['some' => 'config'], + 'watchers' => [], + 'computed' => [], + 'type' => 'FORM', + 'title' => 'Test Screen', + 'description' => '', + 'screen_category_id' => null, + 'nested' => [], + ]; + + $screenCache->expects($this->once()) + ->method('get') ->with($screenKey) - ->andReturn(null); - ScreenCompiledManager::shouldReceive('storeCompiledContent') - ->once() - ->withAnyArgs($screenKey, $content) - ->andReturn(null); + ->willReturn($cachedContent); - // Assert the expected screen content is returned - $response = $this->apiCall('GET', route('api.1.1.tasks.show.screen', $task->id) . '?include=screen,nested'); + // Make the API call + $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/Feature/Cache/CacheManagerBaseTest.php b/tests/Feature/Cache/CacheManagerBaseTest.php new file mode 100644 index 0000000000..b051663f0b --- /dev/null +++ b/tests/Feature/Cache/CacheManagerBaseTest.php @@ -0,0 +1,108 @@ +set('cache.default', 'redis'); + } + + protected function tearDown(): void + { + config()->set('cache.default', 'array'); + + parent::tearDown(); + } + + public function testGetKeysByPatternWithValidConnectionAndMatchingKeys() + { + $this->cacheManagerBase = $this->getMockForAbstractClass(CacheManagerBase::class); + + $pattern = 'test-pattern'; + $prefix = config('cache.prefix'); + $keys = [$prefix . ':test-pattern:1', $prefix . ':test-pattern:2']; + + Redis::shouldReceive('connection') + ->with('redis') + ->andReturnSelf(); + + Redis::shouldReceive('keys') + ->with($prefix . '*') + ->andReturn($keys); + + $result = $this->cacheManagerBase->getKeysByPattern($pattern); + + $this->assertCount(2, $result); + $this->assertEquals($keys, $result); + } + + public function testGetKeysByPatternWithValidConnectionAndNoMatchingKeys() + { + $this->cacheManagerBase = $this->getMockForAbstractClass(CacheManagerBase::class); + + $pattern = 'non-matching-pattern'; + $prefix = config('cache.prefix'); + $keys = [$prefix . ':test-pattern:1', $prefix . ':test-pattern:2']; + + Redis::shouldReceive('connection') + ->with('redis') + ->andReturnSelf(); + + Redis::shouldReceive('keys') + ->with($prefix . '*') + ->andReturn($keys); + + $result = $this->cacheManagerBase->getKeysByPattern($pattern); + + $this->assertCount(0, $result); + } + + public function testGetKeysByPatternWithInvalidConnection() + { + config()->set('cache.default', 'array'); + + $this->cacheManagerBase = $this->getMockForAbstractClass(CacheManagerBase::class); + + $this->expectException(CacheManagerException::class); + $this->expectExceptionMessage('`getKeysByPattern` method only supports Redis connections.'); + + $this->cacheManagerBase->getKeysByPattern('pattern'); + } + + public function testGetKeysByPatternWithExceptionDuringKeyRetrieval() + { + $this->cacheManagerBase = $this->getMockForAbstractClass(CacheManagerBase::class); + + $pattern = 'test-pattern'; + $prefix = config('cache.prefix'); + + Redis::shouldReceive('connection') + ->with('redis') + ->andReturnSelf(); + + Redis::shouldReceive('keys') + ->with($prefix . '*') + ->andThrow(new Exception('Redis error')); + + Log::shouldReceive('info') + ->with('CacheManagerBase: ' . 'Redis error') + ->once(); + + $result = $this->cacheManagerBase->getKeysByPattern($pattern); + + $this->assertCount(0, $result); + } +} diff --git a/tests/Feature/Cache/SettingCacheManagerTest.php b/tests/Feature/Cache/SettingCacheManagerTest.php new file mode 100644 index 0000000000..c14349e06f --- /dev/null +++ b/tests/Feature/Cache/SettingCacheManagerTest.php @@ -0,0 +1,102 @@ +with($key, $default) + ->andReturn($expected); + + $result = \SettingCache::get($key, $default); + + $this->assertEquals($expected, $result); + } + + public function testSet() + { + $key = 'test_key'; + $value = 'test_value'; + $ttl = 60; + + \SettingCache::shouldReceive('set') + ->with($key, $value, $ttl) + ->andReturn(true); + + $result = \SettingCache::set($key, $value, $ttl); + + $this->assertTrue($result); + } + + public function testDelete() + { + $key = 'test_key'; + + \SettingCache::shouldReceive('delete') + ->with($key) + ->andReturn(true); + + $result = \SettingCache::delete($key); + + $this->assertTrue($result); + } + + public function testClear() + { + \SettingCache::shouldReceive('clear') + ->andReturn(true); + + $result = \SettingCache::clear(); + + $this->assertTrue($result); + } + + public function testHas() + { + $key = 'test_key'; + + \SettingCache::shouldReceive('has') + ->with($key) + ->andReturn(true); + + $result = \SettingCache::has($key); + + $this->assertTrue($result); + } + + public function testMissing() + { + $key = 'test_key'; + + \SettingCache::shouldReceive('missing') + ->with($key) + ->andReturn(false); + + $result = \SettingCache::missing($key); + + $this->assertFalse($result); + } + + public function testCall() + { + $method = 'add'; + $arguments = ['arg1', 'arg2']; + $expected = 'cached_value'; + + \SettingCache::shouldReceive($method) + ->with(...$arguments) + ->andReturn($expected); + + $result = \SettingCache::__call($method, $arguments); + + $this->assertEquals($expected, $result); + } +} diff --git a/tests/Feature/Cache/SettingCacheTest.php b/tests/Feature/Cache/SettingCacheTest.php new file mode 100644 index 0000000000..a1ee312b48 --- /dev/null +++ b/tests/Feature/Cache/SettingCacheTest.php @@ -0,0 +1,324 @@ +user = User::factory()->create([ + 'is_administrator' => true, + ]); + + config()->set('cache.default', 'cache_settings'); + } + + protected function tearDown(): void + { + \SettingCache::clear(); + + config()->set('cache.default', 'array'); + + parent::tearDown(); + } + + private function upgrade() + { + $this->artisan('migrate', [ + '--path' => 'upgrades/2023_11_30_185738_add_password_policies_settings.php', + ])->run(); + } + + public static function trackQueries(): void + { + DB::enableQueryLog(); + } + + public static function flushQueryLog(): void + { + DB::flushQueryLog(); + } + + public static function getQueriesExecuted(): array + { + return DB::getQueryLog(); + } + + public static function getQueryCount(): int + { + return count(self::getQueriesExecuted()); + } + + public function testGetSettingByKeyCached(): void + { + $this->upgrade(); + + $key = 'password-policies.users_can_change'; + $cacheKey = 'setting_password-policies.users_can_change'; + $setting = Setting::where('key', $key)->first(); + \SettingCache::set($cacheKey, $setting); + + $this->trackQueries(); + + $setting = Setting::byKey($key); + + $this->assertEquals(0, self::getQueryCount()); + $this->assertEquals($key, $setting->key); + } + + public function testGetSettingByKeyNotCached(): void + { + $key = 'password-policies.uppercase'; + + $this->upgrade(); + $this->trackQueries(); + + $setting = Setting::byKey($key); + + $this->assertEquals(1, self::getQueryCount()); + $this->assertEquals($key, $setting->key); + + $this->flushQueryLog(); + + $setting = Setting::byKey($key); + $this->assertEquals(0, self::getQueryCount()); + $this->assertNotNull($setting); + $this->assertEquals($key, $setting->key); + } + + public function testGetSettingByKeyCachedAfterUpdate(): void + { + $key = 'password-policies.special'; + + $this->upgrade(); + $this->trackQueries(); + + $setting = Setting::byKey($key); + + $this->assertEquals(1, self::getQueryCount()); + $this->assertEquals($key, $setting->key); + $this->assertEquals($setting->config, 1); + + $data = array_merge($setting->toArray(), ['config' => false]); + + $response = $this->apiCall('PUT', route('api.settings.update', ['setting' => $setting->id]), $data); + $response->assertStatus(204); + + $this->flushQueryLog(); + + $setting = Setting::byKey($key); + $this->assertEquals(0, self::getQueryCount()); + $this->assertEquals($key, $setting->key); + $this->assertEquals($setting->config, 0); + } + + public function testGetSettingByNotExistingKey() + { + $this->withoutExceptionHandling(); + $key = 'non-existing-key'; + + $callback = fn () => Setting::where('key', $key)->first(); + + $this->expectException(\InvalidArgumentException::class); + $setting = \SettingCache::getOrCache($key, $callback); + + $this->assertNull($setting); + } + + public function testClearByPattern() + { + Cache::store('cache_settings')->put('password-policies.users_can_change', 1); + Cache::store('cache_settings')->put('password-policies.numbers', 2); + Cache::store('cache_settings')->put('password-policies.uppercase', 3); + Cache::put('session-control.ip_restriction', 0); + + $this->assertEquals(1, Cache::store('cache_settings')->get('password-policies.users_can_change')); + $this->assertEquals(2, Cache::store('cache_settings')->get('password-policies.numbers')); + $this->assertEquals(3, Cache::store('cache_settings')->get('password-policies.uppercase')); + + $pattern = 'password-policies'; + + \SettingCache::clearBy($pattern); + + $this->assertNull(Cache::store('cache_settings')->get('password-policies.users_can_change')); + $this->assertNull(Cache::store('cache_settings')->get('password-policies.numbers')); + $this->assertNull(Cache::store('cache_settings')->get('password-policies.uppercase')); + } + + public function testClearByPatternRemainUnmatched() + { + Cache::store('cache_settings')->put('session-control.ip_restriction', 0); + Cache::store('cache_settings')->put('password-policies.users_can_change', 1); + Cache::store('cache_settings')->put('password-policies.numbers', 2); + Cache::store('cache_settings')->put('password-policies.uppercase', 3); + + $this->assertEquals(0, Cache::store('cache_settings')->get('session-control.ip_restriction')); + $this->assertEquals(1, Cache::store('cache_settings')->get('password-policies.users_can_change')); + $this->assertEquals(2, Cache::store('cache_settings')->get('password-policies.numbers')); + $this->assertEquals(3, Cache::store('cache_settings')->get('password-policies.uppercase')); + + $pattern = 'password-policies'; + + \SettingCache::clearBy($pattern); + + $this->assertEquals(0, Cache::store('cache_settings')->get('session-control.ip_restriction')); + $this->assertNull(Cache::store('cache_settings')->get('password-policies.users_can_change')); + $this->assertNull(Cache::store('cache_settings')->get('password-policies.numbers')); + $this->assertNull(Cache::store('cache_settings')->get('password-policies.uppercase')); + } + + public function testClearByPatternWithFailedDeletion() + { + $pattern = 'test_pattern'; + $keys = [ + 'settings:test_pattern:1', + 'settings:test_pattern:2', + ]; + \SettingCache::set('test_pattern:1', 1); + \SettingCache::set('test_pattern:2', 2); + + // Set up the expectation for the connection method + Redis::shouldReceive('connection') + ->with('cache_settings') + ->andReturnSelf(); + + Redis::shouldReceive('keys') + ->with('settings:*') + ->andReturn($keys); + + Redis::shouldReceive('del') + ->with($keys) + ->andThrow(new SettingCacheException('Failed to delete keys.')); + + $this->expectException(SettingCacheException::class); + $this->expectExceptionMessage('Failed to delete keys.'); + + \SettingCache::clearBy($pattern); + } + + public function testTryClearByPatternWithNonRedisDriver() + { + config()->set('cache.default', 'array'); + + $this->expectException(SettingCacheException::class); + $this->expectExceptionMessage('The cache driver must be Redis.'); + + \SettingCache::clearBy('pattern'); + } + + public function testClearAllSettings() + { + \SettingCache::set('password-policies.users_can_change', 1); + \SettingCache::set('password-policies.numbers', 2); + \SettingCache::set('password-policies.uppercase', 3); + + $this->assertEquals(1, \SettingCache::get('password-policies.users_can_change')); + $this->assertEquals(2, \SettingCache::get('password-policies.numbers')); + $this->assertEquals(3, \SettingCache::get('password-policies.uppercase')); + + \SettingCache::clear(); + + $this->assertNull(\SettingCache::get('password-policies.users_can_change')); + $this->assertNull(\SettingCache::get('password-policies.numbers')); + $this->assertNull(\SettingCache::get('password-policies.uppercase')); + } + + public function testClearOnlySettings() + { + \SettingCache::set('password-policies.users_can_change', 1); + \SettingCache::set('password-policies.numbers', 2); + + Cache::store('file')->put('password-policies.uppercase', 3); + + $this->assertEquals(1, \SettingCache::get('password-policies.users_can_change')); + $this->assertEquals(2, \SettingCache::get('password-policies.numbers')); + + $this->assertEquals(3, Cache::store('file')->get('password-policies.uppercase')); + + \SettingCache::clear(); + + $this->assertNull(\SettingCache::get('password-policies.users_can_change')); + $this->assertNull(\SettingCache::get('password-policies.numbers')); + + $this->assertEquals(3, Cache::store('file')->get('password-policies.uppercase')); + } + + public function testInvalidateOnSaved() + { + $setting = Setting::factory()->create([ + 'key' => 'password-policies.users_can_change', + 'config' => 1, + 'format' => 'boolean', + ]); + + \SettingCache::set($setting->key, $setting); + $settingCache = \SettingCache::get($setting->key); + + $this->assertEquals(1, $settingCache->config); + + $setting->update(['config' => 0]); + $settingCache = \SettingCache::get($setting->key); + $this->assertNull($settingCache); + } + + public function testInvalidateOnDeleted() + { + $setting = Setting::factory()->create([ + 'key' => 'password-policies.users_can_change', + 'config' => 1, + 'format' => 'boolean', + ]); + + \SettingCache::set($setting->key, $setting); + $settingCache = \SettingCache::get($setting->key); + + $this->assertEquals(1, $settingCache->config); + + $setting->delete(); + $settingCache = \SettingCache::get($setting->key); + $this->assertNull($settingCache); + } + + public function testInvalidateWithException() + { + $setting = Setting::factory()->create([ + 'key' => 'password-policies.numbers', + 'config' => 1, + 'format' => 'boolean', + ]); + + \SettingCache::set($setting->key, $setting); + $settingCache = \SettingCache::get($setting->key); + + $this->assertEquals(1, $settingCache->config); + + \SettingCache::shouldReceive('invalidate') + ->with(['key' => $setting->key]) + ->andThrow(new SettingCacheException('Failed to invalidate cache KEY:' . $setting->key)) + ->once(); + \SettingCache::shouldReceive('clear') + ->once() + ->andReturn(true); + + $this->expectException(SettingCacheException::class); + $this->expectExceptionMessage('Failed to invalidate cache KEY:' . $setting->key); + \SettingCache::invalidate(['key' => $setting->key]); + + $setting->delete(); + } +} diff --git a/tests/Feature/Metrics/TaskMetricsTest.php b/tests/Feature/Metrics/TaskMetricsTest.php new file mode 100644 index 0000000000..b133316a7d --- /dev/null +++ b/tests/Feature/Metrics/TaskMetricsTest.php @@ -0,0 +1,53 @@ +create([ + 'is_administrator' => true, + ]); + + $bpmnFile = 'tests/Fixtures/single_task_with_screen.bpmn'; + $process = $this->createProcessFromBPMN($bpmnFile, [ + 'user_id' => $user->id, + ]); + + $this->be($user); + + $startEvent = $process->getDefinitions()->getStartEvent('node_1'); + $request = WorkflowManager::triggerStartEvent($process, $startEvent, []); + + $formTask = $request->tokens()->where('element_id', 'node_2')->firstOrFail(); + + // Complete the task + WorkflowManager::completeTask($process, $request, $formTask, ['someValue' => 123]); + + // Verify that the metric was stored + $this->assertMetricWasStored('activity_completed_total', [ + 'activity_id' => 'node_2', + 'activity_name' => 'Form Task', + 'process_id' => $process->id, + 'request_id' => $request->id, + ]); + } + + private function assertMetricWasStored(string $name, array $labels) + { + $adapter = Metrics::getCollectionRegistry(); + $ns = config('app.prometheus_namespace', 'app'); + $metric = $adapter->getCounter($ns, $name); + + $this->assertInstanceOf(Counter::class, $metric); + $this->assertEquals($metric->getLabelNames(), array_keys($labels)); + } +} diff --git a/tests/Feature/MetricsFacadeTest.php b/tests/Feature/MetricsFacadeTest.php new file mode 100644 index 0000000000..a1200280ae --- /dev/null +++ b/tests/Feature/MetricsFacadeTest.php @@ -0,0 +1,58 @@ +inc(); + + // Verify that the metric was registered and incremented + $this->assertTrue(true); // In this point we assume that there are no errors + } + + /** + * Test to check if metrics can be rendered using the facade. + */ + public function test_facade_can_render_metrics() + { + // Register and increment a counter + $counter = Metrics::counter('facade_render_test', 'Render test via facade'); + $counter->inc(); + + // Render the metrics + $output = Metrics::renderMetrics(); + + // Verify the metric in the output + $this->assertStringContainsString('facade_render_test', $output); + } +} diff --git a/tests/Feature/Screens/ScreenCompiledManagerTest.php b/tests/Feature/Screens/ScreenCompiledManagerTest.php index 471877a0ff..f7d9656638 100644 --- a/tests/Feature/Screens/ScreenCompiledManagerTest.php +++ b/tests/Feature/Screens/ScreenCompiledManagerTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use Illuminate\Filesystem\FileNotFoundException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use ProcessMaker\Managers\ScreenCompiledManager; @@ -157,27 +158,33 @@ public function it_clears_process_screens_cache() } /** - * Validate a screen key can be created + * Validate that a screen key can be created with various process versions, screen versions, and languages. * * @test */ - public function it_creates_a_screen_key() + public function it_creates_a_screen_key_with_various_versions() { // Arrange $manager = new ScreenCompiledManager(); $processId = '123'; - $processVersionId = '1'; - $language = 'en'; + $processVersionIds = ['1', '2', '3']; + $languages = ['en', 'es', 'fr', 'de', 'it', 'pt', 'zh', 'ja', 'ru', 'ar']; $screenId = '456'; - $screenVersionId = '1'; + $screenVersionIds = ['1', '2']; - $expectedKey = 'pid_123_1_en_sid_456_1'; + foreach ($processVersionIds as $processVersionId) { + foreach ($screenVersionIds as $screenVersionId) { + foreach ($languages as $language) { + $expectedKey = "pid_{$processId}_{$processVersionId}_{$language}_sid_{$screenId}_{$screenVersionId}"; - // Create the screen key - $screenKey = $manager->createKey($processId, $processVersionId, $language, $screenId, $screenVersionId); + // Create the screen key + $screenKey = $manager->createKey($processId, $processVersionId, $language, $screenId, $screenVersionId); - // Assert - $this->assertEquals($expectedKey, $screenKey); + // Assert + $this->assertEquals($expectedKey, $screenKey); + } + } + } } /** @@ -203,4 +210,241 @@ public function it_gets_the_last_screen_version_id() // Assert the ID is the expected one $this->assertEquals($expectedId, $lastId); } + + /** + * Validate storing compiled content with empty content + * + * @test + */ + public function it_stores_empty_compiled_content() + { + // Arrange + $manager = new ScreenCompiledManager(); + $screenKey = 'empty_screen_key'; + $compiledContent = ''; + + // Act + $manager->storeCompiledContent($screenKey, $compiledContent); + + // Assert + $filename = 'screen_' . $screenKey . '.bin'; + $storagePath = $this->storagePath . $filename; + + Storage::disk($this->storageDisk)->assertExists($storagePath); + $storedContent = Storage::disk($this->storageDisk)->get($storagePath); + $this->assertEquals(serialize($compiledContent), $storedContent); + } + + /** + * Validate exception handling when storage is unavailable + * + * @test + */ + public function it_handles_storage_exceptions() + { + // Arrange + $manager = new ScreenCompiledManager(); + $screenKey = 'exception_screen_key'; + $compiledContent = ['key' => 'value']; + + // Simulate storage exception + Storage::shouldReceive('disk->put') + ->andThrow(new \Exception('Storage unavailable')); + + // Act & Assert + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Storage unavailable'); + + $manager->storeCompiledContent($screenKey, $compiledContent); + } + + /** + * Validate clearing compiled assets when directory does not exist + * + * @test + */ + public function it_clears_compiled_assets_when_directory_does_not_exist() + { + // Arrange + $manager = new ScreenCompiledManager(); + + // Ensure directory does not exist + Storage::disk($this->storageDisk)->deleteDirectory($this->storagePath); + + // Act + $manager->clearCompiledAssets(); + + // Assert the directory has been recreated + Storage::disk($this->storageDisk)->assertExists($this->storagePath); + } + + /** + * Validate that storing compiled content fails with invalid data + * + * @test + */ + public function it_fails_with_invalid_screen_key() + { + // Arrange + $manager = new ScreenCompiledManager(); + + // Test cases with invalid screen keys + $invalidKeys = [ + '', // Empty string + null, // Null value + str_repeat('a', 1000), // Extremely long key + '../../malicious/path', // Path traversal attempt + 'special@#$%chars', // Special characters + ]; + + foreach ($invalidKeys as $invalidKey) { + try { + $manager->storeCompiledContent($invalidKey, ['test' => 'content']); + $this->fail('Expected exception was not thrown for key: ' . (string) $invalidKey); + } catch (\TypeError|\Exception $e) { + // Assert that an exception was thrown + $this->assertTrue(true); + } + } + } + + /** + * Test handling of storage limit scenarios when storing compiled screen content + * + * @test + */ + public function it_handles_storage_limit_scenarios() + { + // Arrange + $manager = new ScreenCompiledManager(); + $screenKey = $manager->createKey('1', '1', 'en', '1', '1'); + $compiledContent = ['test' => 'content']; + + // Simulate storage limit reached by throwing a specific exception + Storage::shouldReceive('disk->put') + ->andThrow(new \Exception('Storage limit reached')); + + // Act & Assert + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Storage limit reached'); + + // Attempt to store compiled content, expecting an exception + $manager->storeCompiledContent($screenKey, $compiledContent); + } + + /** + * Test deleting compiled content for a specific screen ID and language + * + * @test + */ + public function it_deletes_screen_compiled_content() + { + // Arrange + $manager = new ScreenCompiledManager(); + $screenId = '5'; + $language = 'es'; + $compiledContent = ['key' => 'value']; + + // Create test files that should be deleted + $filesToDelete = [ + "pid_19_63_{$language}_sid_{$screenId}_7", + "pid_20_64_{$language}_sid_{$screenId}_8", + ]; + + // Create test files that should not be deleted + $filesToKeep = [ + "pid_19_63_en_sid_{$screenId}_7", // Different language + "pid_19_63_{$language}_sid_6_7", // Different screen ID + 'pid_19_63_fr_sid_6_7', // Different language and screen ID + ]; + + // Store all test files + foreach ($filesToDelete as $key) { + $manager->storeCompiledContent($key, $compiledContent); + } + foreach ($filesToKeep as $key) { + $manager->storeCompiledContent($key, $compiledContent); + } + + // Act + $result = $manager->deleteScreenCompiledContent($screenId, $language); + + // Assert + $this->assertTrue($result); + + // Verify files that should be deleted are gone + foreach ($filesToDelete as $key) { + $filename = 'screen_' . $key . '.bin'; + Storage::disk($this->storageDisk)->assertMissing($this->storagePath . $filename); + } + + // Verify files that should be kept still exist + foreach ($filesToKeep as $key) { + $filename = 'screen_' . $key . '.bin'; + Storage::disk($this->storageDisk)->assertExists($this->storagePath . $filename); + } + } + + /** + * Test deleting compiled content when no matching files exist + * + * @test + */ + public function it_returns_false_when_no_files_match_delete_pattern() + { + // Arrange + $manager = new ScreenCompiledManager(); + $screenId = '5'; + $language = 'es'; + $compiledContent = ['key' => 'value']; + + // Create test files that should not be deleted + $filesToKeep = [ + 'pid_19_63_en_sid_6_7', + 'pid_19_63_fr_sid_6_7', + ]; + + // Store test files + foreach ($filesToKeep as $key) { + $manager->storeCompiledContent($key, $compiledContent); + } + + // Act + $result = $manager->deleteScreenCompiledContent($screenId, $language); + + // Assert + $this->assertFalse($result); + + // Verify all files still exist + foreach ($filesToKeep as $key) { + $filename = 'screen_' . $key . '.bin'; + Storage::disk($this->storageDisk)->assertExists($this->storagePath . $filename); + } + } + + /** + * Test deleting compiled content with special characters in language code + * + * @test + */ + public function it_handles_special_characters_in_language_code() + { + // Arrange + $manager = new ScreenCompiledManager(); + $screenId = '5'; + $language = 'zh-CN'; // Language code with special character + $compiledContent = ['key' => 'value']; + + // Create test file with special character in language code + $key = "pid_19_63_{$language}_sid_{$screenId}_7"; + $manager->storeCompiledContent($key, $compiledContent); + + // Act + $result = $manager->deleteScreenCompiledContent($screenId, $language); + + // Assert + $this->assertTrue($result); + $filename = 'screen_' . $key . '.bin'; + Storage::disk($this->storageDisk)->assertMissing($this->storagePath . $filename); + } } diff --git a/tests/unit/MetricsServiceTest.php b/tests/unit/MetricsServiceTest.php new file mode 100644 index 0000000000..9b809eed9d --- /dev/null +++ b/tests/unit/MetricsServiceTest.php @@ -0,0 +1,126 @@ +metricsService = new MetricsService($adapter); + App::instance(MetricsService::class, $this->metricsService); // Replace the service in the container + } + + /** + * Test the counter registration and increment. + */ + public function testCounterRegistrationAndIncrement(): void + { + $counter = $this->metricsService->counter('test_counter', 'Test Counter', ['label1']); + + // Assert the counter is registered + $this->assertInstanceOf(\Prometheus\Counter::class, $counter); + + // Increment the counter and assert the value + $counter->inc(['value1']); + $samples = $this->metricsService->renderMetrics(); + $this->assertStringContainsString('test_counter', $samples); + $this->assertStringContainsString('value1', $samples); + } + + /** + * Test the gauge registration and set. + */ + public function testGaugeRegistrationAndSet(): void + { + $gauge = $this->metricsService->gauge('test_gauge', 'Test Gauge', ['label1']); + + // Assert the gauge is registered + $this->assertInstanceOf(\Prometheus\Gauge::class, $gauge); + + // Set the gauge value and assert the value + $gauge->set(10, ['value1']); + $samples = $this->metricsService->renderMetrics(); + $this->assertStringContainsString('test_gauge', $samples); + $this->assertStringContainsString('10', $samples); + } + + /** + * Test the histogram registration and observe. + */ + public function testHistogramRegistrationAndObserve(): void + { + $histogram = $this->metricsService->histogram( + 'test_histogram', + 'Test Histogram', + ['label1'], + [0.1, 1, 5] + ); + + // Assert the histogram is registered + $this->assertInstanceOf(\Prometheus\Histogram::class, $histogram); + + // Observe a value and assert it is recorded + $histogram->observe(0.5, ['value1']); + $samples = $this->metricsService->renderMetrics(); + $this->assertStringContainsString('test_histogram', $samples); + $this->assertStringContainsString('0.5', $samples); + } + + /** + * Test the renderMetrics method. + */ + public function testRenderMetrics(): void + { + $counter = $this->metricsService->counter('render_test', 'Render Test Counter', ['label']); + $counter->inc(['value1']); + + $metrics = $this->metricsService->renderMetrics(); + $this->assertStringContainsString('render_test', $metrics); + } + + /** + * Test the default namespace. + */ + public function testDefaultNamespace(): void + { + $counter = $this->metricsService->counter('namespace_test'); + + // Assert default namespace is applied + $this->assertInstanceOf(\Prometheus\Counter::class, $counter); + $counter->inc(); + + $samples = $this->metricsService->renderMetrics(); + + $this->assertStringContainsString('namespace_test', $samples); + } + + /** + * Test the gauge set method. + */ + public function testSetGaugeValue(): void + { + $this->metricsService->gauge('test_set_gauge', 'Gauge Test', ['label'])->set(5, ['label_value']); + $samples = $this->metricsService->renderMetrics(); + + $this->assertStringContainsString('test_set_gauge', $samples); + $this->assertStringContainsString('5', $samples); + } +} diff --git a/tests/unit/ProcessMaker/Cache/Monitoring/CacheMetricsDecoratorTest.php b/tests/unit/ProcessMaker/Cache/Monitoring/CacheMetricsDecoratorTest.php new file mode 100644 index 0000000000..372c971167 --- /dev/null +++ b/tests/unit/ProcessMaker/Cache/Monitoring/CacheMetricsDecoratorTest.php @@ -0,0 +1,395 @@ +cache = Mockery::mock(CacheInterface::class . ', ' . ScreenCacheInterface::class); + $this->metrics = Mockery::mock(CacheMetricsInterface::class); + + // Create decorator with mocks + $this->decorator = new CacheMetricsDecorator($this->cache, $this->metrics); + } + + public function testGetWithHit() + { + // Setup expectations for cache hit + $this->cache->shouldReceive('has') + ->once() + ->with($this->testKey) + ->andReturn(true); + + $this->cache->shouldReceive('get') + ->once() + ->with($this->testKey, null) + ->andReturn($this->testValue); + + $this->metrics->shouldReceive('recordHit') + ->once() + ->withArgs(function ($key, $time) { + return $key === $this->testKey && is_float($time); + }); + + // Execute and verify + $result = $this->decorator->get($this->testKey); + $this->assertEquals($this->testValue, $result); + } + + public function testGetWithMiss() + { + $default = 'default_value'; + + // Setup expectations for cache miss + $this->cache->shouldReceive('has') + ->once() + ->with($this->testKey) + ->andReturn(false); + + $this->cache->shouldReceive('get') + ->once() + ->with($this->testKey, $default) + ->andReturn($default); + + $this->metrics->shouldReceive('recordMiss') + ->once() + ->withArgs(function ($key, $time) { + return $key === $this->testKey && is_float($time); + }); + + // Execute and verify + $result = $this->decorator->get($this->testKey, $default); + $this->assertEquals($default, $result); + } + + public function testSetSuccess() + { + $ttl = 3600; + + // Setup expectations + $this->cache->shouldReceive('set') + ->once() + ->with($this->testKey, $this->testValue, $ttl) + ->andReturn(true); + + $this->metrics->shouldReceive('recordWrite') + ->once() + ->withArgs(function ($key, $size) { + return $key === $this->testKey && is_int($size) && $size > 0; + }); + + // Execute and verify + $result = $this->decorator->set($this->testKey, $this->testValue, $ttl); + $this->assertTrue($result); + } + + public function testSetFailure() + { + // Setup expectations + $this->cache->shouldReceive('set') + ->once() + ->with($this->testKey, $this->testValue, null) + ->andReturn(false); + + $this->metrics->shouldNotReceive('recordWrite'); + + // Execute and verify + $result = $this->decorator->set($this->testKey, $this->testValue); + $this->assertFalse($result); + } + + public function testDelete() + { + // Setup expectations + $this->cache->shouldReceive('delete') + ->once() + ->with($this->testKey) + ->andReturn(true); + + // Execute and verify + $result = $this->decorator->delete($this->testKey); + $this->assertTrue($result); + } + + public function testClear() + { + // Setup expectations + $this->cache->shouldReceive('clear') + ->once() + ->andReturn(true); + + // Execute and verify + $result = $this->decorator->clear(); + $this->assertTrue($result); + } + + public function testHas() + { + // Setup expectations + $this->cache->shouldReceive('has') + ->once() + ->with($this->testKey) + ->andReturn(true); + + // Execute and verify + $result = $this->decorator->has($this->testKey); + $this->assertTrue($result); + } + + public function testMissing() + { + // Setup expectations + $this->cache->shouldReceive('missing') + ->once() + ->with($this->testKey) + ->andReturn(true); + + // Execute and verify + $result = $this->decorator->missing($this->testKey); + $this->assertTrue($result); + } + + public function testCalculateSizeWithString() + { + $value = 'test'; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(strlen($value), $result); + } + + public function testCalculateSizeWithArray() + { + $value = ['test' => 'value']; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(strlen(serialize($value)), $result); + } + + public function testCalculateSizeWithObject() + { + $value = new \stdClass(); + $value->test = 'value'; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(strlen(serialize($value)), $result); + } + + public function testCalculateSizeWithInteger() + { + $value = 42; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(PHP_INT_SIZE, $result); + } + + public function testCalculateSizeWithFloat() + { + $value = 3.14; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(8, $result); + } + + public function testCalculateSizeWithBoolean() + { + $value = true; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(1, $result); + } + + public function testCalculateSizeWithNull() + { + $value = null; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(0, $result); + } + + protected function invokeCalculateSize($value) + { + $method = new \ReflectionMethod(CacheMetricsDecorator::class, 'calculateSize'); + $method->setAccessible(true); + + return $method->invoke($this->decorator, $value); + } + + public function testCreateKey() + { + // Setup expectations + $this->cache->shouldReceive('createKey') + ->once() + ->with([ + 'process_id' => 1, + 'process_version_id' => 2, + 'language' => 'en', + 'screen_id' => 3, + 'screen_version_id' => 4, + ]) + ->andReturn('screen_1_2_en_3_4'); + + // Execute and verify + $key = $this->decorator->createKey([ + 'process_id' => 1, + 'process_version_id' => 2, + 'language' => 'en', + 'screen_id' => 3, + 'screen_version_id' => 4, + ]); + $this->assertEquals('screen_1_2_en_3_4', $key); + } + + public function testCreateKeyWithNonScreenCache() + { + // Create a mock that only implements CacheInterface + $cache = Mockery::mock(CacheInterface::class); + $cache->shouldReceive('createKey') + ->once() + ->andThrow(new \BadMethodCallException('Method Mockery_0_ProcessMaker_Cache_CacheInterface::createKey() does not exist on this mock object')); + + $metrics = Mockery::mock(CacheMetricsInterface::class); + $decorator = new CacheMetricsDecorator($cache, $metrics); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Method Mockery_0_ProcessMaker_Cache_CacheInterface::createKey() does not exist on this mock object'); + + $decorator->createKey([ + 'process_id' => 1, + 'process_version_id' => 2, + 'language' => 'en', + 'screen_id' => 3, + 'screen_version_id' => 4, + ]); + } + + public function testInvalidateSuccess() + { + // Test parameters + $params = ['screen_id' => 5, 'language' => 'es']; + + // Setup expectations for invalidate + $this->cache->shouldReceive('invalidate') + ->once() + ->with($params) + ->andReturn(true); + + // Execute and verify + $result = $this->decorator->invalidate($params); + $this->assertNull($result); + } + + public function testInvalidateFailure() + { + // Test parameters + $params = ['screen_id' => 5, 'language' => 'es']; + + // Setup expectations for invalidate to fail + $this->cache->shouldReceive('invalidate') + ->once() + ->with($params) + ->andReturnNull(); + + // Execute and verify + $result = $this->decorator->invalidate($params); + $this->assertNull($result); + } + + public function testInvalidateWithNonScreenCache() + { + // Create a mock that implements CacheInterface + $cache = Mockery::mock(CacheInterface::class); + $cache->shouldReceive('invalidate') + ->once() + ->andThrow(new \BadMethodCallException('Call to undefined method Mock_CacheInterface_27913466::invalidate()')); + + $metrics = Mockery::mock(CacheMetricsInterface::class); + $decorator = new CacheMetricsDecorator($cache, $metrics); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Call to undefined method Mock_CacheInterface_27913466::invalidate()'); + + // Execute with test parameters + $decorator->invalidate(['screen_id' => 5, 'language' => 'es']); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testGetWithPrometheusMetricLabel() + { + $mockMetric = Mockery::mock(PrometheusMetricInterface::class); + $mockMetric->shouldReceive('getPrometheusMetricLabel') + ->once() + ->andReturn('prometheus_label'); + + // Setup expectations for cache hit + $this->cache->shouldReceive('has') + ->once() + ->with($this->testKey) + ->andReturn(true); + + $this->cache->shouldReceive('get') + ->once() + ->with($this->testKey, null) + ->andReturn($mockMetric); + + $this->metrics->shouldReceive('recordHit') + ->once() + ->withArgs(function ($key, $time, $labels) { + return $key === $this->testKey && is_float($time) && $labels['label'] === 'prometheus_label'; + }); + + // Execute and verify + $result = $this->decorator->get($this->testKey); + $this->assertEquals($mockMetric, $result); + } + + public function testSetWithPrometheusMetricLabel() + { + $mockMetric = new MockMetric(); + + $ttl = 3600; + + // Setup expectations + $this->cache->shouldReceive('set') + ->once() + ->with($this->testKey, $mockMetric, $ttl) + ->andReturn(true); + + $this->metrics->shouldReceive('recordWrite') + ->once() + ->withArgs(function ($key, $size, $labels) { + return $key === $this->testKey && is_int($size) && $size > 0 && $labels['label'] === 'prometheus_label'; + }); + + // Execute and verify + $result = $this->decorator->set($this->testKey, $mockMetric, $ttl); + $this->assertTrue($result); + } +} + +class MockMetric implements PrometheusMetricInterface +{ + public function getPrometheusMetricLabel(): string + { + return 'prometheus_label'; + } +} \ No newline at end of file diff --git a/tests/unit/ProcessMaker/Cache/Monitoring/RedisMetricsManagerTest.php b/tests/unit/ProcessMaker/Cache/Monitoring/RedisMetricsManagerTest.php new file mode 100644 index 0000000000..04f30acba7 --- /dev/null +++ b/tests/unit/ProcessMaker/Cache/Monitoring/RedisMetricsManagerTest.php @@ -0,0 +1,187 @@ +metrics = new RedisMetricsManager(); + + // Get metrics prefix using reflection + $reflection = new \ReflectionClass(RedisMetricsManager::class); + $this->metricsPrefix = $reflection->getConstant('METRICS_PREFIX'); + + // Clear any existing metrics before each test + $this->metrics->resetMetrics(); + } + + public function testRecordHit() + { + $time = 0.1; + $this->metrics->recordHit($this->testKey, $time); + + $baseKey = $this->metricsPrefix . $this->testKey; + $hits = Redis::hget($baseKey, 'hits'); + $times = Redis::lrange($baseKey . ':hit_times', 0, -1); + + $this->assertEquals(1, $hits); + $this->assertCount(1, $times); + $this->assertEquals($time, (float) $times[0]); + } + + public function testRecordMiss() + { + $time = 0.2; + $this->metrics->recordMiss($this->testKey, $time); + + $baseKey = $this->metricsPrefix . $this->testKey; + $misses = Redis::hget($baseKey, 'misses'); + $times = Redis::lrange($baseKey . ':miss_times', 0, -1); + + $this->assertEquals(1, $misses); + $this->assertCount(1, $times); + $this->assertEquals($time, (float) $times[0]); + } + + public function testRecordWrite() + { + $size = 1024; + $this->metrics->recordWrite($this->testKey, $size); + + $baseKey = $this->metricsPrefix . $this->testKey; + $storedSize = Redis::hget($baseKey, 'memory'); + $lastWrite = Redis::hget($baseKey, 'last_write'); + + $this->assertEquals($size, $storedSize); + $this->assertNotNull($lastWrite); + $this->assertIsNumeric($lastWrite); + } + + public function testGetHitRate() + { + // Record 2 hits and 1 miss + $this->metrics->recordHit($this->testKey, 0.1); + $this->metrics->recordHit($this->testKey, 0.1); + $this->metrics->recordMiss($this->testKey, 0.2); + + $hitRate = $this->metrics->getHitRate($this->testKey); + $this->assertEqualsWithDelta(2 / 3, $hitRate, 0.0001); + } + + public function testGetMissRate() + { + // Record 2 hits and 1 miss + $this->metrics->recordHit($this->testKey, 0.1); + $this->metrics->recordHit($this->testKey, 0.1); + $this->metrics->recordMiss($this->testKey, 0.2); + + $missRate = $this->metrics->getMissRate($this->testKey); + $this->assertEqualsWithDelta(1 / 3, $missRate, 0.0001); + } + + public function testGetHitAvgTime() + { + $this->metrics->recordHit($this->testKey, 0.1); + $this->metrics->recordHit($this->testKey, 0.3); + + $avgTime = $this->metrics->getHitAvgTime($this->testKey); + $this->assertEqualsWithDelta(0.2, $avgTime, 0.0001); + } + + public function testGetMissAvgTime() + { + $this->metrics->recordMiss($this->testKey, 0.2); + $this->metrics->recordMiss($this->testKey, 0.4); + + $avgTime = $this->metrics->getMissAvgTime($this->testKey); + $this->assertEqualsWithDelta(0.3, $avgTime, 0.0001); + } + + public function testGetTopKeys() + { + // Setup test data + $this->metrics->recordHit('key1', 0.1); + $this->metrics->recordHit('key1', 0.1); + $this->metrics->recordMiss('key1', 0.2); + $this->metrics->recordWrite('key1', 1000); + + $this->metrics->recordHit('key2', 0.1); + $this->metrics->recordWrite('key2', 500); + + $topKeys = $this->metrics->getTopKeys(2); + + $this->assertCount(2, $topKeys); + $this->assertEquals('key1', $topKeys['key1']['key']); + $this->assertEquals(3, $topKeys['key1']['total_accesses']); + $this->assertEquals(1000, $topKeys['key1']['memory_usage']); + } + + public function testGetMemoryUsage() + { + $size = 2048; + $this->metrics->recordWrite($this->testKey, $size); + + $usage = $this->metrics->getMemoryUsage($this->testKey); + + $this->assertEquals($size, $usage['current_size']); + $this->assertNotNull($usage['last_write']); + $this->assertIsFloat($usage['last_write']); + } + + public function testResetMetrics() + { + // Add some test data + $this->metrics->recordHit($this->testKey, 0.1); + $this->metrics->recordMiss($this->testKey, 0.2); + $this->metrics->recordWrite($this->testKey, 1024); + + // Reset metrics + $this->metrics->resetMetrics(); + + // Verify all metrics are cleared + $keys = Redis::keys($this->metricsPrefix . '*'); + $this->assertEmpty($keys); + } + + public function testGetSummary() + { + // Setup test data + $this->metrics->recordHit('key1', 0.1); + $this->metrics->recordHit('key1', 0.3); + $this->metrics->recordMiss('key1', 0.2); + $this->metrics->recordWrite('key1', 1000); + + $this->metrics->recordHit('key2', 0.2); + $this->metrics->recordWrite('key2', 500); + + $summary = $this->metrics->getSummary(); + + $this->assertArrayHasKey('keys', $summary); + $this->assertArrayHasKey('overall_hit_ratio', $summary); + $this->assertArrayHasKey('overall_miss_ratio', $summary); + $this->assertArrayHasKey('avg_hit_time', $summary); + $this->assertArrayHasKey('avg_miss_time', $summary); + $this->assertArrayHasKey('total_memory_usage', $summary); + $this->assertEquals(2, $summary['total_keys']); + $this->assertEquals(1500, $summary['total_memory_usage']); + } + + protected function tearDown(): void + { + // Clean up after each test + $this->metrics->resetMetrics(); + parent::tearDown(); + } +} diff --git a/tests/unit/ProcessMaker/Cache/Screens/LegacyScreenCacheAdapterTest.php b/tests/unit/ProcessMaker/Cache/Screens/LegacyScreenCacheAdapterTest.php new file mode 100644 index 0000000000..e9861869d3 --- /dev/null +++ b/tests/unit/ProcessMaker/Cache/Screens/LegacyScreenCacheAdapterTest.php @@ -0,0 +1,213 @@ +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([ + 'process_id' => 1, + 'process_version_id' => 2, + 'language' => 'en', + 'screen_id' => 3, + 'screen_version_id' => 4, + ]); + + $this->assertEquals('pid_1_2_en_sid_3_4', $key); + } + + /** @test */ + public function it_throws_exception_when_missing_required_parameters() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Missing required parameters for screen cache key'); + + $this->adapter->createKey([ + 'process_id' => 1, + // Missing process_version_id + 'language' => 'en', + 'screen_id' => 3, + 'screen_version_id' => 4, + ]); + } + + /** @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); + } + + /** @test */ + public function testInvalidateSuccess() + { + // Test parameters + $screenId = 5; + $language = 'es'; + + // Setup expectations + $this->compiledManager->shouldReceive('deleteScreenCompiledContent') + ->once() + ->with($screenId, $language) + ->andReturn(true); + + // Execute and verify + $result = $this->adapter->invalidate(['screen_id' => $screenId, 'language' => $language]); + $this->assertNull($result); + } + + /** @test */ + public function testInvalidateFailure() + { + // Test parameters + $screenId = 5; + $language = 'es'; + + // Setup expectations for failure + $this->compiledManager->shouldReceive('deleteScreenCompiledContent') + ->once() + ->with($screenId, $language) + ->andReturn(false); + + // Execute and verify + $result = $this->adapter->invalidate(['screen_id' => $screenId, 'language' => $language]); + $this->assertNull($result); + } + + /** @test */ + public function testInvalidateWithSpecialLanguageCode() + { + // Test parameters with special language code + $screenId = 5; + $language = 'zh-CN'; + + // Setup expectations + $this->compiledManager->shouldReceive('deleteScreenCompiledContent') + ->once() + ->with($screenId, $language) + ->andReturn(true); + + // Execute and verify + $result = $this->adapter->invalidate(['screen_id' => $screenId, 'language' => $language]); + $this->assertNull($result); + } + + /** @test */ + public function testInvalidateWithEmptyResults() + { + // Test parameters + $screenId = 999; // Non-existent screen ID + $language = 'es'; + + // Setup expectations for no files found + $this->compiledManager->shouldReceive('deleteScreenCompiledContent') + ->once() + ->with($screenId, $language) + ->andReturn(false); + + // Execute and verify + $result = $this->adapter->invalidate(['screen_id' => $screenId, 'language' => $language]); + $this->assertNull($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..aa930cea40 --- /dev/null +++ b/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheFactoryTest.php @@ -0,0 +1,258 @@ +app->singleton(ScreenCompiledManager::class); + $this->app->singleton(RedisMetricsManager::class); + } + + public function testCreateNewCacheManager() + { + Config::set('screens.cache.manager', 'new'); + + // Create a mock for ScreenCacheManager + $mockManager = $this->createMock(ScreenCacheManager::class); + $this->app->instance(ScreenCacheManager::class, $mockManager); + + $cache = ScreenCacheFactory::create(app('cache'), app(RedisMetricsManager::class)); + + // Should be wrapped with metrics decorator + $this->assertInstanceOf(CacheMetricsDecorator::class, $cache); + + // Get the underlying cache implementation + $reflection = new \ReflectionClass($cache); + $property = $reflection->getProperty('cache'); + $property->setAccessible(true); + $underlyingCache = $property->getValue($cache); + + // Verify it's the new cache manager + $this->assertInstanceOf(ScreenCacheManager::class, $underlyingCache); + } + + public function testCreateLegacyCacheAdapter() + { + Config::set('screens.cache.manager', 'legacy'); + + $cache = ScreenCacheFactory::create(app('cache'), app(RedisMetricsManager::class)); + + // Should be wrapped with metrics decorator + $this->assertInstanceOf(CacheMetricsDecorator::class, $cache); + + // Get the underlying cache implementation + $reflection = new \ReflectionClass($cache); + $property = $reflection->getProperty('cache'); + $property->setAccessible(true); + $underlyingCache = $property->getValue($cache); + + // Verify it's the legacy adapter + $this->assertInstanceOf(LegacyScreenCacheAdapter::class, $underlyingCache); + } + + public function testMetricsIntegrationWithBothAdapters() + { + // Test with new cache manager + Config::set('screens.cache.manager', 'new'); + + // Create a mock for ScreenCacheManager + $mockManager = $this->createMock(ScreenCacheManager::class); + $this->app->instance(ScreenCacheManager::class, $mockManager); + + $newCache = ScreenCacheFactory::create(app('cache'), app(RedisMetricsManager::class)); + $this->verifyMetricsDecorator($newCache, ScreenCacheManager::class); + + // Test with legacy adapter + Config::set('screens.cache.manager', 'legacy'); + $legacyCache = ScreenCacheFactory::create(app('cache'), app(RedisMetricsManager::class)); + $this->verifyMetricsDecorator($legacyCache, LegacyScreenCacheAdapter::class); + } + + protected function verifyMetricsDecorator($cache, $expectedCacheClass) + { + // Verify it's a metrics decorator + $this->assertInstanceOf(CacheMetricsDecorator::class, $cache); + + // Get reflection for property access + $reflection = new \ReflectionClass($cache); + + // Check cache implementation + $cacheProperty = $reflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $this->assertInstanceOf($expectedCacheClass, $cacheProperty->getValue($cache)); + + // Check metrics manager + $metricsProperty = $reflection->getProperty('metrics'); + $metricsProperty->setAccessible(true); + $this->assertInstanceOf(RedisMetricsManager::class, $metricsProperty->getValue($cache)); + } + + /** + * Test invalidate with new cache manager + * + * @test + */ + public function testInvalidateWithNewCacheManager() + { + Config::set('screens.cache.manager', 'new'); + + // Create a mock for ScreenCacheManager + $mockManager = $this->createMock(ScreenCacheManager::class); + $mockManager->expects($this->once()) + ->method('invalidate') + ->with(['screen_id' => 5, 'language' => 'es']); + + $this->app->instance(ScreenCacheManager::class, $mockManager); + + $cache = ScreenCacheFactory::create( + app('cache'), + app(RedisMetricsManager::class) + ); + + $cache->invalidate(['screen_id' => 5, 'language' => 'es']); + + // No assertion needed since we verified the method was called with expects() + } + + /** + * Test invalidate with legacy cache adapter + * + * @test + */ + public function testInvalidateWithLegacyCache() + { + Config::set('screens.cache.manager', 'legacy'); + + // Create mock for ScreenCompiledManager + $mockCompiledManager = $this->createMock(ScreenCompiledManager::class); + $mockCompiledManager->expects($this->once()) + ->method('deleteScreenCompiledContent') + ->with(5, 'es') + ->willReturn(true); + + $this->app->instance(ScreenCompiledManager::class, $mockCompiledManager); + + $cache = ScreenCacheFactory::create(app('cache'), app(RedisMetricsManager::class)); + $result = $cache->invalidate(['screen_id' => 5, 'language' => 'es']); + + $this->assertNull($result); + } + + /** + * Test getScreenCache method returns same instance as create + * + * @test + */ + public function testGetScreenCacheReturnsSameInstanceAsCreate() + { + // Get instances using both methods + $instance1 = ScreenCacheFactory::create(app('cache'), app(RedisMetricsManager::class)); + $instance2 = ScreenCacheFactory::getScreenCache(); + + // Verify they are the same type and have same metrics wrapper + $this->assertInstanceOf(CacheMetricsDecorator::class, $instance1); + $this->assertInstanceOf(CacheMetricsDecorator::class, $instance2); + + // Get underlying cache implementations + $reflection = new \ReflectionClass(CacheMetricsDecorator::class); + $property = $reflection->getProperty('cache'); + $property->setAccessible(true); + + $cache1 = $property->getValue($instance1); + $cache2 = $property->getValue($instance2); + + // Verify underlying implementations are of same type + $this->assertEquals(get_class($cache1), get_class($cache2)); + } + + /** + * Test factory respects test instance + * + * @test + */ + public function testFactoryRespectsTestInstance() + { + // Create a mock for ScreenCacheInterface + $mockInterface = $this->createMock(CacheInterface::class); + + // Set the test instance in the factory + ScreenCacheFactory::setTestInstance($mockInterface); + + // Retrieve the instance from the factory + $instance = ScreenCacheFactory::create(app('cache'), app(RedisMetricsManager::class)); + + // Assert that the instance is the mock we set + $this->assertSame($mockInterface, $instance); + } + + /** + * Test metrics decoration is applied correctly + * + * @test + */ + public function testMetricsDecorationIsAppliedCorrectly() + { + // Test with both cache types + $cacheTypes = ['new', 'legacy']; + + foreach ($cacheTypes as $type) { + Config::set('screens.cache.manager', $type); + + $cache = ScreenCacheFactory::create(app('cache'), app(RedisMetricsManager::class)); + + // Verify outer wrapper is metrics decorator + $this->assertInstanceOf(CacheMetricsDecorator::class, $cache); + + // Get and verify metrics instance + $reflection = new \ReflectionClass(CacheMetricsDecorator::class); + $metricsProperty = $reflection->getProperty('metrics'); + $metricsProperty->setAccessible(true); + + $metrics = $metricsProperty->getValue($cache); + $this->assertInstanceOf(RedisMetricsManager::class, $metrics); + } + } + + /** + * Test factory with invalid configuration + * + * @test + */ + public function testFactoryWithInvalidConfiguration() + { + Config::set('screens.cache.manager', 'invalid'); + + // Should default to legacy cache + $cache = ScreenCacheFactory::create(app('cache'), app(RedisMetricsManager::class)); + + $reflection = new \ReflectionClass(CacheMetricsDecorator::class); + $property = $reflection->getProperty('cache'); + $property->setAccessible(true); + + $underlyingCache = $property->getValue($cache); + $this->assertInstanceOf(LegacyScreenCacheAdapter::class, $underlyingCache); + } + + protected function tearDown(): void + { + ScreenCacheFactory::setTestInstance(null); + parent::tearDown(); + } +} diff --git a/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheManagerTest.php b/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheManagerTest.php new file mode 100644 index 0000000000..448753e6b0 --- /dev/null +++ b/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheManagerTest.php @@ -0,0 +1,329 @@ +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) { + $params = [ + 'process_id' => 1, + 'process_version_id' => 2, + 'language' => $lang, + 'screen_id' => 3, + 'screen_version_id' => 4, + ]; + $key = $this->screenCache->createKey($params); + $expectedKey = "screen_pid_1_2_{$lang}_sid_3_4"; + + $this->assertEquals($expectedKey, $key); + } + } + + /** @test */ + public function testStoresAndRetrievesFromMemoryCache() + { + $key = 'test_screen'; + $value = ['content' => 'test']; + $serializedValue = serialize($value); + + // Set up expectations + $this->cacheManager->shouldReceive('put') + ->once() + ->with($key, $serializedValue, 86400) + ->andReturn(true); + + $this->cacheManager->shouldReceive('get') + ->once() + ->withArgs([$key]) + ->andReturn($serializedValue); + + // 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() + ->withArgs([$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']; + $serializedNestedContent = serialize($nestedContent); + $parentContent = [ + 'component' => 'FormScreen', + 'config' => [ + 'screenId' => 123, + 'content' => $nestedContent, + ], + ]; + $serializedParentContent = serialize($parentContent); + + // Set up expectations for nested screen + $this->cacheManager->shouldReceive('get') + ->once() + ->withArgs([$nestedKey]) + ->andReturn($serializedNestedContent); + + $this->cacheManager->shouldReceive('put') + ->once() + ->with($key, $serializedParentContent, 86400) + ->andReturn(true); + + $this->cacheManager->shouldReceive('get') + ->once() + ->withArgs([$key]) + ->andReturn($serializedParentContent); + + // Store and retrieve parent screen + $this->screenCache->set($key, $parentContent); + $result = $this->screenCache->get($key); + $this->screenCache->get($nestedKey); // Add this line to call get() with 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') + ->withArgs([$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') + ->withArgs(['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') + ->with($key, $serializedValue, 86400) + ->andReturn(true); + + $this->screenCache->set($key, $value); + Redis::incrBy('screen_cache:stats:size', strlen(serialize($value))); + $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)); + } + + /** @test */ + public function testInvalidateSuccess() + { + // Test parameters + $screenId = 3; + $language = 'en'; + $pattern = "*_{$language}_sid_{$screenId}_*"; + + // Set up expectations for get and forget + $this->cacheManager->shouldReceive('get') + ->once() + ->with($pattern) + ->andReturn(['key1', 'key2']); + + $this->cacheManager->shouldReceive('forget') + ->twice() + ->andReturn(true); + + // Execute and verify + $result = $this->screenCache->invalidate(['screen_id' => $screenId, 'language' => $language]); + $this->assertNull($result); + } + + /** @test */ + public function testInvalidateFailure() + { + // Test parameters + $screenId = 3; + $language = 'en'; + $pattern = "*_{$language}_sid_{$screenId}_*"; + + // Set up expectations for get and forget + $this->cacheManager->shouldReceive('get') + ->once() + ->with($pattern) + ->andReturn(['key1']); // Return a key to delete + + $this->cacheManager->shouldReceive('forget') + ->once() + ->andReturn(false); // Make forget operation fail + + // Execute and verify + $result = $this->screenCache->invalidate(['screen_id' => $screenId, 'language' => $language]); + $this->assertNull($result); + } + + protected function tearDown(): void + { + parent::tearDown(); + } +} diff --git a/tests/unit/ProcessMaker/HasScreenFieldsTest.php b/tests/unit/ProcessMaker/HasScreenFieldsTest.php new file mode 100644 index 0000000000..69d0b6a7bc --- /dev/null +++ b/tests/unit/ProcessMaker/HasScreenFieldsTest.php @@ -0,0 +1,142 @@ +create([ + 'id' => 1, + 'config' => [ + [ + 'items' => [ + [ + 'component' => 'FormInput', + 'config' => [ + 'name' => 'field1', + 'label' => 'Field 1', + 'dataFormat' => 'string', + ], + ], + [ + 'component' => 'FormInput', + 'config' => [ + 'name' => 'field2', + 'label' => 'Field 2', + 'dataFormat' => 'string', + ], + ], + ], + ], + ], + ]); + $expectedFields = [ + [ + 'label' => 'Field 1', + 'field' => 'field1', + 'sortable' => true, + 'default' => false, + 'format' => 'string', + 'mask' => null, + 'isSubmitButton' => false, + 'encryptedConfig' => null, + ], + [ + 'label' => 'Field 2', + 'field' => 'field2', + 'sortable' => true, + 'default' => false, + 'format' => 'string', + 'mask' => null, + 'isSubmitButton' => false, + 'encryptedConfig' => null, + ], + ]; + $key = $screenCache->createKey([ + 'process_id' => 0, + 'process_version_id' => 0, + 'language' => 'all', + 'screen_id' => (int) $screen->id, + 'screen_version_id' => 0, + ]) . '_fields'; + $screenCache->set($key, null); + + $fields = json_decode(json_encode($screen->fields), true); + + $cacheFields = json_decode(json_encode($screenCache->get($key)), true); + + $this->assertEquals($expectedFields, $fields); + $this->assertEquals($expectedFields, $cacheFields); + } + + public function testLoadScreenFieldsFromCache() + { + $screenCache = ScreenCacheFactory::getScreenCache(); + $screen = Screen::factory()->create([ + 'id' => 1, + 'config' => [ + [ + 'items' => [ + [ + 'component' => 'FormInput', + 'config' => [ + 'name' => 'field1', + 'label' => 'Field 1', + 'dataFormat' => 'string', + ], + ], + [ + 'component' => 'FormInput', + 'config' => [ + 'name' => 'field2', + 'label' => 'Field 2', + 'dataFormat' => 'string', + ], + ], + ], + ], + ], + ]); + $expectedFields = [ + [ + 'label' => 'Field 1 (cached)', + 'field' => 'field1', + 'sortable' => true, + 'default' => false, + 'format' => 'string', + 'mask' => null, + 'isSubmitButton' => false, + 'encryptedConfig' => null, + ], + [ + 'label' => 'Field 2 (cached)', + 'field' => 'field2', + 'sortable' => true, + 'default' => false, + 'format' => 'string', + 'mask' => null, + 'isSubmitButton' => false, + 'encryptedConfig' => null, + ], + ]; + $key = $screenCache->createKey([ + 'process_id' => 0, + 'process_version_id' => 0, + 'language' => 'all', + 'screen_id' => (int) $screen->id, + 'screen_version_id' => 0, + ]) . '_fields'; + $screenCache->set($key, $expectedFields); + + $fields = json_decode(json_encode($screen->fields), true); + + $cacheFields = json_decode(json_encode($screenCache->get($key)), true); + + $this->assertEquals($expectedFields, $fields); + $this->assertEquals($expectedFields, $cacheFields); + } +}