diff --git a/ProcessMaker/Console/Commands/UnblockRequest.php b/ProcessMaker/Console/Commands/UnblockRequest.php index f23aba0e6b..110ccf3f17 100644 --- a/ProcessMaker/Console/Commands/UnblockRequest.php +++ b/ProcessMaker/Console/Commands/UnblockRequest.php @@ -64,7 +64,7 @@ public function handle(): int $retryRequest->retry(); - foreach ($retryRequest::$output as $line) { + foreach ($retryRequest->getOutput() as $line) { $this->info($line); } diff --git a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php index 0a4daa0991..0ce0efe099 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php @@ -304,7 +304,7 @@ public function retry(ProcessRequest $request, Request $httpRequest): JsonRespon $retryRequest->retry(); return response()->json([ - 'message' => $retryRequest::$output, + 'message' => $retryRequest->getOutput(), 'success' => true, ]); } catch (Throwable $throwable) { diff --git a/ProcessMaker/Http/Middleware/ServerTimingMiddleware.php b/ProcessMaker/Http/Middleware/ServerTimingMiddleware.php index 2a15e84a6d..958daa6707 100644 --- a/ProcessMaker/Http/Middleware/ServerTimingMiddleware.php +++ b/ProcessMaker/Http/Middleware/ServerTimingMiddleware.php @@ -5,22 +5,19 @@ use Closure; use Illuminate\Http\Request; use ProcessMaker\Providers\ProcessMakerServiceProvider; +use ProcessMaker\Services\WorkerBootTimingService; use Symfony\Component\HttpFoundation\Response; class ServerTimingMiddleware { - // Minimum time in ms to include a package in the Server-Timing header - private static $minPackageTime; - - public function __construct() + public function __construct(private WorkerBootTimingService $workerBootTimingService) { - self::$minPackageTime = config('app.server_timing.min_package_time'); } /** * Handle an incoming request. * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + * @param Closure(Request): (Response) $next */ public function handle(Request $request, Closure $next): Response { @@ -39,7 +36,7 @@ public function handle(Request $request, Closure $next): Response // Calculate execution times $controllerTime = (microtime(true) - $startController) * 1000; // Convert to ms // Fetch service provider boot time - $serviceProviderTime = ProcessMakerServiceProvider::getBootTime() ?? 0; + $serviceProviderTime = $this->workerBootTimingService->getProviderBootTime() ?? 0; // Fetch query time $queryTime = ProcessMakerServiceProvider::getQueryTime() ?? 0; @@ -55,13 +52,14 @@ public function handle(Request $request, Closure $next): Response array_unshift($serverTiming, "boot;dur={$bootTiming}"); } - $packageTimes = ProcessMakerServiceProvider::getPackageBootTiming(); + $packageTimes = $this->workerBootTimingService->getPackageBootTiming(); + $minPackageTime = config('app.server_timing.min_package_time'); foreach ($packageTimes as $package => $timing) { $time = ($timing['end'] - $timing['start']) * 1000; // Only include packages that took more than MIN_PACKAGE_TIME ms - if ($time > self::$minPackageTime) { + if ($time > $minPackageTime) { $serverTiming[] = "{$package};dur={$time}"; } } diff --git a/ProcessMaker/ImportExport/Manifest.php b/ProcessMaker/ImportExport/Manifest.php index ac5372b777..9f2bce95bb 100644 --- a/ProcessMaker/ImportExport/Manifest.php +++ b/ProcessMaker/ImportExport/Manifest.php @@ -21,6 +21,12 @@ class Manifest private static $logger = null; + public static function resetRequestState(): void + { + self::$parents = null; + self::$logger = null; + } + public function has(string $uuid) { return array_key_exists($uuid, $this->manifest); diff --git a/ProcessMaker/Jobs/BpmnAction.php b/ProcessMaker/Jobs/BpmnAction.php index f78ddaf647..eefc44a149 100644 --- a/ProcessMaker/Jobs/BpmnAction.php +++ b/ProcessMaker/Jobs/BpmnAction.php @@ -14,10 +14,10 @@ use Illuminate\Support\Facades\Log; use ProcessMaker\BpmnEngine; use ProcessMaker\Exception\HttpABTestingException; -use ProcessMaker\Listeners\HandleRedirectListener; use ProcessMaker\Models\Process as Definitions; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestLock; +use ProcessMaker\Services\RedirectToEventService; use Throwable; abstract class BpmnAction implements ShouldQueue @@ -60,6 +60,7 @@ abstract class BpmnAction implements ShouldQueue public function handle() { $response = null; + $redirectToEventService = app(RedirectToEventService::class); try { extract($this->loadContext()); $this->engine = $engine; @@ -74,7 +75,7 @@ public function handle() // (e.g. completed, assigned, process completed, etc) // excluding system process (non_persistent_process) if ($this->processId !== 'non_persistent_process') { - HandleRedirectListener::sendRedirectToEvent(); + $redirectToEventService->sendRedirectToEvent(); } } catch (HttpABTestingException $exception) { Log::error($exception->getMessage()); @@ -87,6 +88,7 @@ public function handle() $request->logError($exception, $element); } } finally { + $redirectToEventService->reset(); $this->unlock(); } diff --git a/ProcessMaker/Listeners/HandleRedirectListener.php b/ProcessMaker/Listeners/HandleRedirectListener.php index 7679a73572..490235af17 100644 --- a/ProcessMaker/Listeners/HandleRedirectListener.php +++ b/ProcessMaker/Listeners/HandleRedirectListener.php @@ -2,51 +2,19 @@ namespace ProcessMaker\Listeners; -use ProcessMaker\Events\RedirectToEvent; use ProcessMaker\Models\ProcessRequest; +use ProcessMaker\Services\RedirectToEventService; class HandleRedirectListener { - private static $processRequest = null; - - protected static $redirectionMethod = ''; - - private static $redirectionParams = []; - - protected function setRedirectTo(ProcessRequest $processRequest, string $method, ...$params): void - { - self::$processRequest = $processRequest; - self::$redirectionMethod = $method; - self::$redirectionParams = $params; + public function __construct( + private ?RedirectToEventService $redirectToEventService = null + ) { } - /** - * Reset the static state for Octane compatibility. - * This prevents data leaks between requests in long-running workers. - */ - public static function reset(): void - { - self::$processRequest = null; - self::$redirectionMethod = ''; - self::$redirectionParams = []; - } - - public static function sendRedirectToEvent() + protected function setRedirectTo(ProcessRequest $processRequest, string $method, ...$params): void { - $method = self::$redirectionMethod; - $params = self::$redirectionParams; - $processRequest = self::$processRequest; - - // Only get active tokens if there is a valid process request - if ($processRequest !== null) { - $params['activeTokens'] = ProcessRequest::getActiveTokens($processRequest); - $event = new RedirectToEvent($processRequest, $method, $params); - event($event); - - // Clean params to prevent sending the same redirect multiple times - self::$redirectionParams = []; - self::$redirectionMethod = ''; - self::$processRequest = null; - } + $this->redirectToEventService ??= app(RedirectToEventService::class); + $this->redirectToEventService->setRedirectTo($processRequest, $method, ...$params); } } diff --git a/ProcessMaker/Managers/ControllerAddonsRegistry.php b/ProcessMaker/Managers/ControllerAddonsRegistry.php new file mode 100644 index 0000000000..2159b56523 --- /dev/null +++ b/ProcessMaker/Managers/ControllerAddonsRegistry.php @@ -0,0 +1,45 @@ +addons[] = $config; + } + + /** + * Get configured addons for a controller. + */ + public function getAddons(string $scope, string $method, array $data): array + { + $addons = []; + + foreach ($this->addons as $addon) { + if ($addon['method'] !== $method || $addon['scope'] !== $scope) { + continue; + } + + if (isset($addon['data']) && is_callable($addon['data'])) { + $data = call_user_func($addon['data'], $data); + } + + $addon['content'] = isset($addon['view']) && !isset($addon['content']) + ? view($addon['view'], $data)->render() + : (isset($addon['content']) ? $addon['content'] : ''); + $addon['script'] = isset($addon['script']) && is_string($addon['script']) + ? view($addon['script'], $data)->render() + : ''; + $addons[] = $addon; + } + + return $addons; + } +} diff --git a/ProcessMaker/Models/AnonymousUser.php b/ProcessMaker/Models/AnonymousUser.php index b78c65e1df..3e15c87e08 100644 --- a/ProcessMaker/Models/AnonymousUser.php +++ b/ProcessMaker/Models/AnonymousUser.php @@ -12,6 +12,12 @@ class AnonymousUser extends User protected $table = 'users'; + public static function resolve(): self + { + return static::where('username', '=', static::ANONYMOUS_USERNAME) + ->firstOrFail(); + } + public $isAnonymous = true; public function receivesBroadcastNotificationsOn($notification) diff --git a/ProcessMaker/Multitenancy/SwitchTenant.php b/ProcessMaker/Multitenancy/SwitchTenant.php index e85c1421f7..34e7a5be60 100644 --- a/ProcessMaker/Multitenancy/SwitchTenant.php +++ b/ProcessMaker/Multitenancy/SwitchTenant.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\Routing\UrlGenerator; use Illuminate\Support\Arr; use Illuminate\Support\Env; +use Illuminate\Support\Facades\Context; use Monolog\Handler\RotatingFileHandler; use ProcessMaker\Application; use ProcessMaker\Multitenancy\Broadcasting\TenantAwareBroadcastManager; @@ -17,7 +18,7 @@ class SwitchTenant implements SwitchTenantTask { use UsesMultitenancyConfig; - public static $landlordValues = null; + private const LANDLORD_VALUES_CONTEXT_KEY = 'multitenancy.landlord_values'; /** * Make the given tenant current. @@ -31,9 +32,8 @@ public function makeCurrent(IsTenant $tenant): void \Log::debug('SwitchTenant: ' . $tenant->id, ['domain' => request()->getHost()]); - // Save the landlord values for later use - if (!self::$landlordValues) { - self::$landlordValues = $app->make('config')->all(); + if (!Context::has(self::LANDLORD_VALUES_CONTEXT_KEY)) { + Context::add(self::LANDLORD_VALUES_CONTEXT_KEY, $app->make('config')->all()); } // Set the tenant's domain in the request headers. Used for things like the global url() helper. @@ -70,7 +70,7 @@ public function forgetCurrent(): void private function landlordConfig($key) { - return Arr::get(self::$landlordValues, $key); + return Arr::get(Context::get(self::LANDLORD_VALUES_CONTEXT_KEY), $key); } private function setConfig($key, $value) diff --git a/ProcessMaker/Nayra/Repositories/EntityRepository.php b/ProcessMaker/Nayra/Repositories/EntityRepository.php index 4b10af1a02..2f84e55b7e 100644 --- a/ProcessMaker/Nayra/Repositories/EntityRepository.php +++ b/ProcessMaker/Nayra/Repositories/EntityRepository.php @@ -11,7 +11,7 @@ abstract class EntityRepository { - private static $uid2id = ['requests' =>[], 'tokens' =>[]]; + private $uid2id = ['requests' =>[], 'tokens' =>[]]; abstract public function create(array $transaction): ? Model; @@ -41,16 +41,16 @@ public function resolveId(string $uid): int } // Get record if is not stored previously - if (!isset(self::$uid2id[$type][$uid])) { + if (!isset($this->uid2id[$type][$uid])) { $record = $instance->select('id')->where('uuid', $uid)->first(); if ($record) { - self::$uid2id[$type][$uid] = $record->getKey(); + $this->uid2id[$type][$uid] = $record->getKey(); } else { throw new Exception("The uid {$uid} does not exist in the database"); } } - return self::$uid2id[$type][$uid] ?? 0; + return $this->uid2id[$type][$uid] ?? 0; } /** @@ -71,6 +71,6 @@ public function storeUid(string $uid, int $id): void break; } - self::$uid2id[$type][$uid] = $id; + $this->uid2id[$type][$uid] = $id; } } diff --git a/ProcessMaker/Observers/SettingObserver.php b/ProcessMaker/Observers/SettingObserver.php index e4144ab3fb..1a5a6ed240 100644 --- a/ProcessMaker/Observers/SettingObserver.php +++ b/ProcessMaker/Observers/SettingObserver.php @@ -10,7 +10,7 @@ class SettingObserver { - private static $added_refresh_artisan_caches = false; + private bool $addedRefreshArtisanCaches = false; /** * Handle the setting "created" event. @@ -95,7 +95,7 @@ private function invalidateSettingCache(Setting $setting) // Check to see if we already added the refresh to the app's terminating queue. // This is important for install commands when multiple settings are being created/updated. - if (self::$added_refresh_artisan_caches) { + if ($this->addedRefreshArtisanCaches) { return; } @@ -106,6 +106,6 @@ private function invalidateSettingCache(Setting $setting) RefreshArtisanCaches::dispatchSync(); }); - self::$added_refresh_artisan_caches = true; + $this->addedRefreshArtisanCaches = true; } } diff --git a/ProcessMaker/Octane/ResetRequestState.php b/ProcessMaker/Octane/ResetRequestState.php index 45071e97ad..5dc89ace40 100644 --- a/ProcessMaker/Octane/ResetRequestState.php +++ b/ProcessMaker/Octane/ResetRequestState.php @@ -4,14 +4,21 @@ namespace ProcessMaker\Octane; -use ProcessMaker\Listeners\HandleRedirectListener; +use ProcessMaker\ImportExport\Manifest; use ProcessMaker\Providers\ProcessMakerServiceProvider; +use ProcessMaker\Services\RedirectToEventService; final class ResetRequestState { + public function __construct( + private readonly RedirectToEventService $redirectToEventService + ) { + } + public function handle(): void { ProcessMakerServiceProvider::beginRequestTiming(); - HandleRedirectListener::reset(); + Manifest::resetRequestState(); + $this->redirectToEventService->reset(); } } diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index 4d6040591f..088cc8e4d6 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -56,7 +56,9 @@ use ProcessMaker\Providers\PermissionServiceProvider; use ProcessMaker\Repositories\SettingsConfigRepository; use ProcessMaker\Services\ConditionalRedirectService; +use ProcessMaker\Services\RedirectToEventService; use ProcessMaker\Services\SmartExtractConfiguration; +use ProcessMaker\Services\WorkerBootTimingService; use RuntimeException; use Spatie\Multitenancy\Events\MadeTenantCurrentEvent; use Spatie\Multitenancy\Events\TenantNotFoundForRequestEvent; @@ -67,25 +69,13 @@ */ class ProcessMakerServiceProvider extends ServiceProvider { - // Track the start time for service providers boot - private static $bootStart; - - // Track the boot time for service providers - private static $bootTime; - - // Track the boot time for each package - private static $packageBootTiming = []; - // Track the query time for each request private static $queryTime = 0; - // Track the landlord values for multitenancy - private static $landlordValues = null; - public function boot(): void { // Track the start time for service providers boot - self::$bootStart = microtime(true); + $bootStart = microtime(true); // Set the current tenant $this->setCurrentTenantForConsoleCommands(); @@ -114,11 +104,15 @@ public function boot(): void $this->registerOctaneListeners(); // Hook after service providers boot - self::$bootTime = (microtime(true) - self::$bootStart) * 1000; // Convert to milliseconds + $this->app->make(WorkerBootTimingService::class) + ->setProviderBootTime((microtime(true) - $bootStart) * 1000); } public function register(): void { + // Boot metrics live for the lifetime of the application worker. + $this->app->singleton(WorkerBootTimingService::class); + if (config('app.server_timing.enabled')) { // Listen to query events and accumulate query execution time DB::listen(function ($query) { @@ -139,6 +133,10 @@ public function register(): void return new Managers\LoginManager(); }); + $this->app->singleton(Managers\ControllerAddonsRegistry::class, function () { + return new Managers\ControllerAddonsRegistry(); + }); + /* * Maps our Index Manager as a singleton. The Index Manager is used * to manage customizations to the search indexer. @@ -197,11 +195,12 @@ public function register(): void return new Managers\GlobalScriptsManager(); }); - $this->app->singleton(Models\AnonymousUser::class, function ($app) { - return Models\AnonymousUser::where('username', '=', Models\AnonymousUser::ANONYMOUS_USERNAME) - ->firstOrFail(); + $this->app->scoped(Models\AnonymousUser::class, function ($app) { + return Models\AnonymousUser::resolve(); }); + $this->app->scoped(Observers\SettingObserver::class); + $this->app->singleton(PolicyExtension::class, function ($app) { return new PolicyExtension(); }); @@ -252,6 +251,8 @@ public function register(): void $this->app->instance('tenant-resolved', false); + $this->app->scoped(RedirectToEventService::class); + /** * Conditional Redirect Service * This service is used to evaluate the conditional redirect property of a process request token. @@ -507,16 +508,6 @@ public static function forceHttps(): void } } - /** - * Get the boot time for service providers. - * - * @return float|null - */ - public static function getBootTime(): ?float - { - return self::$bootTime; - } - /** * Reset per-request query timing metrics. */ @@ -535,53 +526,6 @@ public static function getQueryTime(): float return self::$queryTime; } - /** - * Set the boot time for service providers. - * - * @param string $package - * @param float $time - */ - public static function setPackageBootStart(string $package, float $time): void - { - if ($time < 0) { - Log::info("Server Timing: Invalid boot time for package: {$package}, time: {$time}"); - - $time = 0; - } - - self::$packageBootTiming[$package] = [ - 'start' => $time, - 'end' => null, - ]; - } - - /** - * Set the boot time for service providers. - * - * - * @param float $time - */ - public static function setPackageBootedTime(string $package, $time): void - { - if (!isset(self::$packageBootTiming[$package]) || $time < 0) { - Log::info("Server Timing: Invalid booted time for package: {$package}, time: {$time}"); - - return; - } - - self::$packageBootTiming[$package]['end'] = $time; - } - - /** - * Get the boot time for service providers. - * - * @return array - */ - public static function getPackageBootTiming(): array - { - return self::$packageBootTiming; - } - /** * Reset per-request static state between Octane requests. * diff --git a/ProcessMaker/RetryProcessRequest.php b/ProcessMaker/RetryProcessRequest.php index 677a514779..98b68c2ab7 100644 --- a/ProcessMaker/RetryProcessRequest.php +++ b/ProcessMaker/RetryProcessRequest.php @@ -21,9 +21,9 @@ class RetryProcessRequest { - public static array $output = []; + private array $output = []; - private static array $taskTypes = []; + private array $taskTypes = []; private ProcessRequest $processRequest; @@ -60,7 +60,7 @@ public function getRetriableTasks(): Collection public function hasNonRetriableTasks(): bool { - $currentTaskTypes = static::$taskTypes; + $currentTaskTypes = $this->taskTypes; $this->determineTaskTypes(true); @@ -102,7 +102,7 @@ public function retry(): void WorkflowManager::runServiceTask($task, $token); } - static::$output[] = $this->formatOutput($task, $element, $token); + $this->output[] = $this->formatOutput($task, $element, $token); }); $this->createRequestComment(); @@ -166,12 +166,17 @@ public function createRequestComment(): void $comment->save(); } + public function getOutput(): array + { + return $this->output; + } + private function determineTaskTypes(bool $all = false): void { if ($all || app()->runningInConsole()) { - static::$taskTypes = ['scriptTask', 'serviceTask', 'task']; + $this->taskTypes = ['scriptTask', 'serviceTask', 'task']; } else { - static::$taskTypes = ['scriptTask']; + $this->taskTypes = ['scriptTask']; } } @@ -181,7 +186,7 @@ public function retriableTasksQuery(): HasMany $tokensQuery->whereIn('status', ['FAILING', 'ACTIVE', 'ERROR']); - $tokensQuery->whereIn('element_type', static::$taskTypes); + $tokensQuery->whereIn('element_type', $this->taskTypes); return $tokensQuery; } diff --git a/ProcessMaker/Services/RedirectToEventService.php b/ProcessMaker/Services/RedirectToEventService.php new file mode 100644 index 0000000000..4dd67eb8c8 --- /dev/null +++ b/ProcessMaker/Services/RedirectToEventService.php @@ -0,0 +1,75 @@ +processRequest = $processRequest; + $this->redirectionMethod = $method; + $this->redirectionParams = $params; + } + + /** + * Dispatch the pending redirect, including the request's active token IDs. + * + * This method is a no-op when no redirect is pending. Pending state is + * consumed before querying tokens or dispatching the event so an exception + * cannot cause stale request data to be retried or leaked into later work. + * + * @throws \Throwable If active-token retrieval or event dispatch fails + */ + public function sendRedirectToEvent(): void + { + if ($this->processRequest === null) { + return; + } + + $processRequest = $this->processRequest; + $method = $this->redirectionMethod; + $params = $this->redirectionParams; + + // Consume the pending redirect before doing work that may throw. + $this->reset(); + + $params['activeTokens'] = ProcessRequest::getActiveTokens($processRequest); + event(new RedirectToEvent($processRequest, $method, $params)); + } + + /** + * Discard all pending redirect state without dispatching an event. + */ + public function reset(): void + { + $this->processRequest = null; + $this->redirectionMethod = ''; + $this->redirectionParams = []; + } +} diff --git a/ProcessMaker/Services/WorkerBootTimingService.php b/ProcessMaker/Services/WorkerBootTimingService.php new file mode 100644 index 0000000000..0e14ae3de9 --- /dev/null +++ b/ProcessMaker/Services/WorkerBootTimingService.php @@ -0,0 +1,95 @@ + + */ + private array $packageBootTiming = []; + + /** + * Store the ProcessMaker service provider boot duration for this worker. + * + * @param float $time Boot duration in milliseconds + */ + public function setProviderBootTime(float $time): void + { + $this->providerBootTime = $time; + } + + /** + * Get the ProcessMaker service provider boot duration for this worker. + * + * @return float|null Boot duration in milliseconds, or null before it is recorded + */ + public function getProviderBootTime(): ?float + { + return $this->providerBootTime; + } + + /** + * Record when a package service provider starts booting. + * + * Invalid negative timestamps are logged and stored as zero. + * Calling this method again for the same package replaces its prior timing. + * + * @param string $package Package name used in the Server-Timing header + * @param float $time Start timestamp in seconds, as returned by microtime(true) + */ + public function setPackageBootStart(string $package, float $time): void + { + if ($time < 0) { + Log::info("Server Timing: Invalid boot time for package: {$package}, time: {$time}"); + + $time = 0.0; + } + + $this->packageBootTiming[$package] = [ + 'start' => $time, + 'end' => null, + ]; + } + + /** + * Record when a package service provider finishes booting. + * + * Invalid negative timestamps and packages without a recorded start are + * logged and ignored. + * + * @param string $package Package name used in the Server-Timing header + * @param float $time End timestamp in seconds, as returned by microtime(true) + */ + public function setPackageBootedTime(string $package, float $time): void + { + if (!isset($this->packageBootTiming[$package]) || $time < 0) { + Log::info("Server Timing: Invalid booted time for package: {$package}, time: {$time}"); + + return; + } + + $this->packageBootTiming[$package]['end'] = $time; + } + + /** + * Get all package boot timestamps recorded for this worker. + * + * @return array + */ + public function getPackageBootTiming(): array + { + return $this->packageBootTiming; + } +} diff --git a/ProcessMaker/Traits/HasControllerAddons.php b/ProcessMaker/Traits/HasControllerAddons.php index 0889ac1d0d..374bb9c36d 100644 --- a/ProcessMaker/Traits/HasControllerAddons.php +++ b/ProcessMaker/Traits/HasControllerAddons.php @@ -2,10 +2,10 @@ namespace ProcessMaker\Traits; +use ProcessMaker\Managers\ControllerAddonsRegistry; + trait HasControllerAddons { - private static $addons = []; - /** * Get configured addons for this controller * @@ -16,26 +16,7 @@ trait HasControllerAddons */ protected function getPluginAddons($method, array $data) { - if (!isset(static::$addons)) { - return; - } - - $addons = []; - foreach (static::$addons as $addon) { - // The addon must have the requested method and must be associated to the current controller - if ($addon['method'] === $method && $addon['scope'] === get_class($this)) { - if (isset($addon['data']) && is_callable($addon['data'])) { - $data = call_user_func($addon['data'], $data); - } - $addon['content'] = isset($addon['view']) && !isset($addon['content']) - ? view($addon['view'], $data)->render() : (isset($addon['content']) - ? $addon['content'] : ''); - $addon['script'] = isset($addon['script']) && is_string($addon['script']) ? view($addon['script'], $data)->render() : ''; - $addons[] = $addon; - } - } - - return $addons; + return app(ControllerAddonsRegistry::class)->getAddons(static::class, $method, $data); } /** @@ -47,8 +28,6 @@ protected function getPluginAddons($method, array $data) */ public static function registerAddon(array $config) { - // Add the controller to which the addon is attached - $config['scope'] = static::class; - static::$addons[] = $config; + app(ControllerAddonsRegistry::class)->register(static::class, $config); } } diff --git a/ProcessMaker/Traits/PluginServiceProviderTrait.php b/ProcessMaker/Traits/PluginServiceProviderTrait.php index b03160e575..f049697279 100644 --- a/ProcessMaker/Traits/PluginServiceProviderTrait.php +++ b/ProcessMaker/Traits/PluginServiceProviderTrait.php @@ -11,7 +11,7 @@ use ProcessMaker\Managers\IndexManager; use ProcessMaker\Managers\LoginManager; use ProcessMaker\Managers\PackageManager; -use ProcessMaker\Providers\ProcessMakerServiceProvider; +use ProcessMaker\Services\WorkerBootTimingService; /** * Add functionality to control a PM plug-in @@ -22,10 +22,6 @@ trait PluginServiceProviderTrait private $scriptBuilderScripts = []; - private static $bootStart = null; - - private static $bootTime; - public function __construct($app) { parent::__construct($app); @@ -48,15 +44,13 @@ protected function bootServerTiming(): void $package = $this->getPackageName(); $this->booting(function () use ($package) { - self::$bootStart = microtime(true); - - ProcessMakerServiceProvider::setPackageBootStart($package, self::$bootStart); + $this->app->make(WorkerBootTimingService::class) + ->setPackageBootStart($package, microtime(true)); }); $this->booted(function () use ($package) { - self::$bootTime = microtime(true); - - ProcessMakerServiceProvider::setPackageBootedTime($package, self::$bootTime); + $this->app->make(WorkerBootTimingService::class) + ->setPackageBootedTime($package, microtime(true)); }); } diff --git a/tests/Feature/ImportExport/ManifestTest.php b/tests/Feature/ImportExport/ManifestTest.php index c42ac2da83..66ba4782be 100644 --- a/tests/Feature/ImportExport/ManifestTest.php +++ b/tests/Feature/ImportExport/ManifestTest.php @@ -22,6 +22,13 @@ class ManifestTest extends TestCase { use HelperTrait; + protected function tearDown(): void + { + Manifest::resetRequestState(); + + parent::tearDown(); + } + private function mockExporter($dependents) { return $this->mock(ScreenExporter::class, function ($mock) use ($dependents) { diff --git a/tests/Feature/ServerTimingMiddlewareTest.php b/tests/Feature/ServerTimingMiddlewareTest.php index 040401d227..b6457dd9d7 100644 --- a/tests/Feature/ServerTimingMiddlewareTest.php +++ b/tests/Feature/ServerTimingMiddlewareTest.php @@ -2,11 +2,15 @@ namespace Tests\Feature; +use Illuminate\Database\Events\QueryExecuted; +use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Route; +use Laravel\Octane\ApplicationGateway; use ProcessMaker\Http\Middleware\ServerTimingMiddleware; use ProcessMaker\Models\User; use ProcessMaker\Providers\ProcessMakerServiceProvider; +use ProcessMaker\Services\WorkerBootTimingService; use ReflectionClass; use Tests\Feature\Shared\RequestHelper; use Tests\TestCase; @@ -15,6 +19,13 @@ class ServerTimingMiddlewareTest extends TestCase { use RequestHelper; + protected function tearDown(): void + { + ProcessMakerServiceProvider::beginRequestTiming(); + + parent::tearDown(); + } + private function getHeader($response, $header) { $headers = $response->headers->all(); @@ -22,6 +33,29 @@ private function getHeader($response, $header) return $headers[$header]; } + private function getServerTimingHeaderValue($response): string + { + return implode(',', $this->getHeader($response, 'server-timing')); + } + + private function getMetricDuration($response, string $metric): float + { + $serverTiming = implode(',', $this->getHeader($response, 'server-timing')); + + preg_match('/(?:^|,)\\s*' . preg_quote($metric, '/') . ';dur=([\\d.]+)/', $serverTiming, $matches); + + $this->assertArrayHasKey(1, $matches, "The {$metric} metric was not present in the Server-Timing header."); + + return (float) $matches[1]; + } + + private function recordQueryDuration(float $milliseconds): void + { + $connection = DB::connection(); + + event(new QueryExecuted('SELECT 1', [], $milliseconds, $connection)); + } + public function testServerTimingHeaderIncludesAllMetrics() { Route::middleware(ServerTimingMiddleware::class)->get('/test', function () { @@ -85,6 +119,45 @@ public function testQueryTimeIsMeasured() $this->assertGreaterThanOrEqual(200, (float) $dbTime); } + public function testOctaneGatewayIsolatesQueryTimingAcrossConsecutiveRequests() + { + Route::middleware(ServerTimingMiddleware::class)->get('/octane-query/slow', function () { + $this->recordQueryDuration(5000); + + return response()->json(['request' => 'slow']); + }); + + Route::middleware(ServerTimingMiddleware::class)->get('/octane-query/fast', function () { + $this->recordQueryDuration(10); + + return response()->json(['request' => 'fast']); + }); + + $gateway = new ApplicationGateway($this->app, $this->app); + + $firstRequest = Request::create('/octane-query/slow'); + $firstResponse = $gateway->handle($firstRequest); + $firstRequestQueryTime = $this->getMetricDuration($firstResponse, 'db'); + + $this->assertGreaterThanOrEqual(5000, $firstRequestQueryTime); + + $gateway->terminate($firstRequest, $firstResponse); + + $this->assertSame(0.0, ProcessMakerServiceProvider::getQueryTime()); + + $secondRequest = Request::create('/octane-query/fast'); + $secondResponse = $gateway->handle($secondRequest); + $secondRequestQueryTime = $this->getMetricDuration($secondResponse, 'db'); + + $this->assertGreaterThanOrEqual(10, $secondRequestQueryTime); + $this->assertLessThan(5000, $secondRequestQueryTime); + $this->assertLessThan($firstRequestQueryTime, $secondRequestQueryTime); + + $gateway->terminate($secondRequest, $secondResponse); + + $this->assertSame(0.0, ProcessMakerServiceProvider::getQueryTime()); + } + public function testServiceProviderTimeIsMeasured() { // Mock a route @@ -105,6 +178,49 @@ public function testServiceProviderTimeIsMeasured() $this->assertGreaterThanOrEqual(0, (float) $providersTime); } + public function testOctaneGatewayPreservesWorkerBootTimingAcrossConsecutiveRequests() + { + config(['app.server_timing.min_package_time' => 0]); + + $workerTiming = app(WorkerBootTimingService::class); + $workerTiming->setProviderBootTime(12.5); + $workerTiming->setPackageBootStart('four32501-worker-package', 10.0); + $workerTiming->setPackageBootedTime('four32501-worker-package', 10.025); + $expectedPackageTiming = $workerTiming->getPackageBootTiming(); + + Route::middleware(ServerTimingMiddleware::class)->get('/octane-worker-timing', function () { + return response()->json(['message' => 'Octane worker timing test']); + }); + + $firstRequest = Request::create('/octane-worker-timing'); + $firstGateway = new ApplicationGateway($this->app, clone $this->app); + $firstResponse = $firstGateway->handle($firstRequest); + + $this->assertSame(12.5, $this->getMetricDuration($firstResponse, 'provider')); + $this->assertEqualsWithDelta( + 25.0, + $this->getMetricDuration($firstResponse, 'four32501-worker-package'), + 0.001 + ); + + $firstGateway->terminate($firstRequest, $firstResponse); + + $secondRequest = Request::create('/octane-worker-timing'); + $secondGateway = new ApplicationGateway($this->app, clone $this->app); + $secondResponse = $secondGateway->handle($secondRequest); + + $this->assertSame(12.5, $this->getMetricDuration($secondResponse, 'provider')); + $this->assertEqualsWithDelta( + 25.0, + $this->getMetricDuration($secondResponse, 'four32501-worker-package'), + 0.001 + ); + + $secondGateway->terminate($secondRequest, $secondResponse); + + $this->assertSame($expectedPackageTiming, $workerTiming->getPackageBootTiming()); + } + public function testControllerTimingIsMeasuredCorrectly() { // Mock a route @@ -162,6 +278,56 @@ public function testServerTimingOnLogin() $this->assertStringContainsString('db;dur=', $serverTiming[2]); } + public function testPackageTimingRespectsMinPackageTimeThreshold() + { + config([ + 'app.server_timing.enabled' => true, + 'app.server_timing.min_package_time' => 5, + ]); + + $workerTiming = app(WorkerBootTimingService::class); + $workerTiming->setPackageBootStart('foour32507-fast-package', 0.0); + $workerTiming->setPackageBootedTime('foour32507-fast-package', 0.002); + + $workerTiming->setPackageBootStart('foour32507-slow-package', 0.0); + $workerTiming->setPackageBootedTime('foour32507-slow-package', 0.010); + + Route::middleware(ServerTimingMiddleware::class)->get('/package-threshold-test', function () { + return response()->json(['message' => 'Package threshold test']); + }); + + $response = $this->get('/package-threshold-test'); + $response->assertHeader('Server-Timing'); + + $serverTiming = $this->getServerTimingHeaderValue($response); + + $this->assertStringNotContainsString('foour32507-fast-package;dur=', $serverTiming); + $this->assertStringContainsString('foour32507-slow-package;dur=', $serverTiming); + } + + public function testMinPackageTimeReadsConfigPerRequest() + { + config(['app.server_timing.enabled' => true]); + + $workerTiming = app(WorkerBootTimingService::class); + $workerTiming->setPackageBootStart('foour32507-octane-package', 0.0); + $workerTiming->setPackageBootedTime('foour32507-octane-package', 0.008); + + Route::middleware(ServerTimingMiddleware::class)->get('/octane-min-package-test', function () { + return response()->json(['message' => 'Octane min package test']); + }); + + config(['app.server_timing.min_package_time' => 10]); + $responseAboveThreshold = $this->get('/octane-min-package-test'); + $serverTimingAboveThreshold = $this->getServerTimingHeaderValue($responseAboveThreshold); + $this->assertStringNotContainsString('foour32507-octane-package;dur=', $serverTimingAboveThreshold); + + config(['app.server_timing.min_package_time' => 5]); + $responseBelowThreshold = $this->get('/octane-min-package-test'); + $serverTimingBelowThreshold = $this->getServerTimingHeaderValue($responseBelowThreshold); + $this->assertStringContainsString('foour32507-octane-package;dur=', $serverTimingBelowThreshold); + } + public function testServerTimingIfIsDisabled() { config(['app.server_timing.enabled' => false]); diff --git a/tests/unit/ProcessMaker/Jobs/BpmnActionRedirectCleanupTest.php b/tests/unit/ProcessMaker/Jobs/BpmnActionRedirectCleanupTest.php new file mode 100644 index 0000000000..ceb1b27262 --- /dev/null +++ b/tests/unit/ProcessMaker/Jobs/BpmnActionRedirectCleanupTest.php @@ -0,0 +1,25 @@ +shouldReceive('sendRedirectToEvent')->never(); + $redirectToEventService->shouldReceive('reset')->once(); + app()->instance(RedirectToEventService::class, $redirectToEventService); + + $job = new class extends BpmnAction { + protected $definitionsId = -1; + }; + + $this->assertNull($job->handle()); + } +} diff --git a/tests/unit/ProcessMaker/Listeners/HandleRedirectListenerTest.php b/tests/unit/ProcessMaker/Listeners/HandleRedirectListenerTest.php new file mode 100644 index 0000000000..9f6ff1ed13 --- /dev/null +++ b/tests/unit/ProcessMaker/Listeners/HandleRedirectListenerTest.php @@ -0,0 +1,212 @@ +setRedirectTo($processRequest, $method, ...$params); + } + }; + } + + public function test_reset_clears_process_request(): void + { + Event::fake([RedirectToEvent::class]); + + $service = app(RedirectToEventService::class); + $probe = $this->createProbe($service); + $staleRequest = ProcessRequest::factory()->create(); + $currentRequest = ProcessRequest::factory()->create(); + + $probe->queue($staleRequest, 'staleRedirect'); + $service->reset(); + $probe->queue($currentRequest, 'currentRedirect'); + $service->sendRedirectToEvent(); + + Event::assertDispatched(RedirectToEvent::class, function (RedirectToEvent $event) use ($currentRequest) { + return $event->broadcastOn()[0]->name === + 'private-ProcessMaker.Models.ProcessRequest.' . $currentRequest->id; + }); + Event::assertDispatched(RedirectToEvent::class, 1); + } + + public function test_reset_clears_redirection_method(): void + { + Event::fake([RedirectToEvent::class]); + + $service = app(RedirectToEventService::class); + $probe = $this->createProbe($service); + $request = ProcessRequest::factory()->create(); + + $probe->queue($request, 'staleRedirect'); + $service->reset(); + $probe->queue($request, 'currentRedirect'); + $service->sendRedirectToEvent(); + + Event::assertDispatched( + RedirectToEvent::class, + fn (RedirectToEvent $event) => $event->method === 'currentRedirect' + ); + } + + public function test_reset_clears_redirection_params(): void + { + Event::fake([RedirectToEvent::class]); + + $service = app(RedirectToEventService::class); + $probe = $this->createProbe($service); + $request = ProcessRequest::factory()->create(); + + $probe->queue($request, 'processUpdated', ['secret' => 'stale']); + $service->reset(); + $probe->queue($request, 'processUpdated', ['tokenId' => 222]); + $service->sendRedirectToEvent(); + + Event::assertDispatched(RedirectToEvent::class, function (RedirectToEvent $event) { + return $event->params[0] === ['tokenId' => 222] + && !array_key_exists('secret', $event->params[0]); + }); + } + + public function test_reset_prevents_stale_redirect_from_leaking(): void + { + Event::fake([RedirectToEvent::class]); + + $service = app(RedirectToEventService::class); + $this->createProbe($service)->queue( + ProcessRequest::factory()->create(), + 'processUpdated', + ['tokenId' => 123] + ); + + $service->reset(); + $service->sendRedirectToEvent(); + + Event::assertNotDispatched(RedirectToEvent::class); + } + + public function test_reset_can_be_called_multiple_times(): void + { + Event::fake([RedirectToEvent::class]); + + $service = app(RedirectToEventService::class); + $this->createProbe($service)->queue(ProcessRequest::factory()->create(), 'processUpdated'); + + $service->reset(); + $service->reset(); + $service->reset(); + $service->sendRedirectToEvent(); + + Event::assertNotDispatched(RedirectToEvent::class); + } + + public function test_send_redirect_to_event_dispatches_and_clears_state(): void + { + Event::fake([RedirectToEvent::class]); + + $service = app(RedirectToEventService::class); + $this->createProbe($service)->queue(ProcessRequest::factory()->create(), 'processUpdated'); + + $service->sendRedirectToEvent(); + $service->sendRedirectToEvent(); + + Event::assertDispatched( + RedirectToEvent::class, + fn (RedirectToEvent $event) => $event->method === 'processUpdated' + ); + Event::assertDispatched(RedirectToEvent::class, 1); + } + + public function test_full_octane_cycle_guarantees_no_data_leak(): void + { + Event::fake([RedirectToEvent::class]); + + $requestA = ProcessRequest::factory()->create(); + $scopeA = app(RedirectToEventService::class); + $this->createProbe($scopeA)->queue( + $requestA, + 'processCompletedRedirect', + ['tokenA' => 111] + ); + + app(ResetRequestState::class)->handle(); + $scopeA->sendRedirectToEvent(); + Event::assertNotDispatched(RedirectToEvent::class); + + app()->forgetScopedInstances(); + + $scopeB = app(RedirectToEventService::class); + $this->assertNotSame($scopeA, $scopeB); + + $requestB = ProcessRequest::factory()->create(); + $this->createProbe($scopeB)->queue( + $requestB, + 'processUpdated', + ['tokenB' => 222, 'userId' => 999] + ); + $scopeB->sendRedirectToEvent(); + + Event::assertDispatched(RedirectToEvent::class, function (RedirectToEvent $event) use ($requestB) { + return $event->method === 'processUpdated' + && $event->params[0] === ['tokenB' => 222, 'userId' => 999] + && $event->broadcastOn()[0]->name === + 'private-ProcessMaker.Models.ProcessRequest.' . $requestB->id; + }); + Event::assertDispatched(RedirectToEvent::class, 1); + } + + public function test_reset_request_state_triggers_redirect_service_reset(): void + { + $service = Mockery::mock(RedirectToEventService::class); + $service->shouldReceive('reset')->once(); + + (new ResetRequestState($service))->handle(); + } + + public function test_octane_termination_cleans_up_when_redirect_is_never_sent(): void + { + Event::fake([RedirectToEvent::class]); + + $service = app(RedirectToEventService::class); + $this->createProbe($service)->queue( + ProcessRequest::factory()->create(), + 'processUpdated', + ['data' => 'sensitive'] + ); + + event(new RequestTerminated( + $this->app, + $this->app, + Request::create('/first-request'), + new Response() + )); + + app(RedirectToEventService::class)->sendRedirectToEvent(); + + Event::assertNotDispatched(RedirectToEvent::class); + } +} diff --git a/tests/unit/ProcessMaker/Managers/ControllerAddonsRegistryTest.php b/tests/unit/ProcessMaker/Managers/ControllerAddonsRegistryTest.php new file mode 100644 index 0000000000..bc5d018391 --- /dev/null +++ b/tests/unit/ProcessMaker/Managers/ControllerAddonsRegistryTest.php @@ -0,0 +1,136 @@ +registry = new ControllerAddonsRegistry(); + $this->bindRegistryInContainer(); + } + + protected function tearDown(): void + { + Container::setInstance($this->previousContainer); + + parent::tearDown(); + } + + public function test_register_addon_is_retrieved_for_matching_scope_and_method(): void + { + $this->registry->register(UserController::class, [ + 'id' => 'test-addon', + 'method' => 'edit', + 'title' => 'Test Addon', + 'content' => 'addon-content', + ]); + + $addons = $this->registry->getAddons(UserController::class, 'edit', []); + + $this->assertCount(1, $addons); + $this->assertSame('test-addon', $addons[0]['id']); + $this->assertSame('addon-content', $addons[0]['content']); + } + + public function test_addons_from_other_controllers_are_not_returned(): void + { + $this->registry->register(UserController::class, [ + 'id' => 'user-addon', + 'method' => 'edit', + 'content' => 'user-content', + ]); + $this->registry->register('Other\\Controller', [ + 'id' => 'other-addon', + 'method' => 'edit', + 'content' => 'other-content', + ]); + + $addons = $this->registry->getAddons(UserController::class, 'edit', []); + + $this->assertCount(1, $addons); + $this->assertSame('user-addon', $addons[0]['id']); + } + + public function test_get_plugin_addons_does_not_mutate_registered_addons(): void + { + $this->registry->register(UserController::class, [ + 'id' => 'test-addon', + 'method' => 'edit', + 'content' => 'original-content', + ]); + + $this->registry->getAddons(UserController::class, 'edit', []); + $this->registry->getAddons(UserController::class, 'edit', []); + + $addons = $this->registry->getAddons(UserController::class, 'edit', []); + + $this->assertCount(1, $addons); + $this->assertSame('original-content', $addons[0]['content']); + } + + public function test_register_addon_static_method_delegates_to_registry(): void + { + UserController::registerAddon([ + 'id' => 'static-addon', + 'method' => 'edit.settings', + 'content' => 'settings-content', + ]); + + $controller = new UserController(); + $addons = $this->invokeGetPluginAddons($controller, 'edit.settings', []); + + $this->assertCount(1, $addons); + $this->assertSame('static-addon', $addons[0]['id']); + } + + public function test_callable_data_modifier_is_applied_when_resolving_addons(): void + { + $this->registry->register(UserController::class, [ + 'id' => 'callable-addon', + 'method' => 'edit', + 'content' => 'content', + 'data' => fn (array $data) => array_merge($data, ['extra' => 'value']), + ]); + + $addons = $this->registry->getAddons(UserController::class, 'edit', ['base' => 'data']); + + $this->assertCount(1, $addons); + } + + private function bindRegistryInContainer(): void + { + $this->previousContainer = Container::getInstance(); + + $container = new Container(); + $container->singleton(ControllerAddonsRegistry::class, fn () => $this->registry); + Container::setInstance($container); + + if (!function_exists('app')) { + require_once dirname(__DIR__, 4) . '/vendor/laravel/framework/src/Illuminate/Foundation/helpers.php'; + } + } + + /** + * @return array> + */ + private function invokeGetPluginAddons(object $controller, string $method, array $data): array + { + $reflection = new \ReflectionMethod($controller, 'getPluginAddons'); + + return $reflection->invoke($controller, $method, $data); + } +} diff --git a/tests/unit/ProcessMaker/Models/AnonymousUserTest.php b/tests/unit/ProcessMaker/Models/AnonymousUserTest.php new file mode 100644 index 0000000000..864e094c9a --- /dev/null +++ b/tests/unit/ProcessMaker/Models/AnonymousUserTest.php @@ -0,0 +1,64 @@ +app->forgetScopedInstances(); + + parent::tearDown(); + } + + public function test_resolve_returns_anonymous_user_from_database(): void + { + $user = AnonymousUser::resolve(); + + $this->assertInstanceOf(AnonymousUser::class, $user); + $this->assertSame(AnonymousUser::ANONYMOUS_USERNAME, $user->username); + } + + public function test_container_binding_returns_same_instance_within_request(): void + { + $first = app(AnonymousUser::class); + $second = app(AnonymousUser::class); + + $this->assertSame($first, $second); + } + + public function test_container_binding_is_not_reused_across_requests(): void + { + $first = app(AnonymousUser::class); + + $this->app->forgetScopedInstances(); + + $second = app(AnonymousUser::class); + + $this->assertNotSame($first, $second); + $this->assertSame($first->id, $second->id); + } + + public function test_container_binding_reflects_database_changes_after_flush(): void + { + $original = app(AnonymousUser::class); + $originalEmail = $original->email; + + User::where('username', AnonymousUser::ANONYMOUS_USERNAME) + ->update(['email' => 'updated-anon@example.com']); + + $this->app->forgetScopedInstances(); + + $refreshed = app(AnonymousUser::class); + + $this->assertSame('updated-anon@example.com', $refreshed->email); + $this->assertNotSame($originalEmail, $refreshed->email); + + User::where('username', AnonymousUser::ANONYMOUS_USERNAME) + ->update(['email' => $originalEmail]); + } +} diff --git a/tests/unit/ProcessMaker/Multitenancy/SwitchTenantTest.php b/tests/unit/ProcessMaker/Multitenancy/SwitchTenantTest.php new file mode 100644 index 0000000000..e19db9daa0 --- /dev/null +++ b/tests/unit/ProcessMaker/Multitenancy/SwitchTenantTest.php @@ -0,0 +1,53 @@ + 'https://landlord.example.com']); + Context::add(self::LANDLORD_VALUES_KEY, config()->all()); + + config(['app.url' => 'https://tenant-modified.example.com']); + + $this->assertSame( + 'https://landlord.example.com', + $this->landlordConfig('app.url') + ); + } + + public function test_landlord_values_are_not_reused_across_requests(): void + { + Context::add(self::LANDLORD_VALUES_KEY, ['app' => ['url' => 'https://tenant-a.example.com']]); + Context::forget(self::LANDLORD_VALUES_KEY); + + config(['app.url' => 'https://tenant-b.example.com']); + Context::add(self::LANDLORD_VALUES_KEY, config()->all()); + + $this->assertSame( + 'https://tenant-b.example.com', + Context::get(self::LANDLORD_VALUES_KEY)['app']['url'] + ); + } + + private function landlordConfig(string $key): mixed + { + $method = new \ReflectionMethod(SwitchTenant::class, 'landlordConfig'); + + return $method->invoke(new SwitchTenant(), $key); + } +} diff --git a/tests/unit/ProcessMaker/Nayra/Repositories/EntityRepositoryTest.php b/tests/unit/ProcessMaker/Nayra/Repositories/EntityRepositoryTest.php new file mode 100644 index 0000000000..29986e2f77 --- /dev/null +++ b/tests/unit/ProcessMaker/Nayra/Repositories/EntityRepositoryTest.php @@ -0,0 +1,66 @@ +assertFalse( + $reflection->isStatic(), + 'uid2id must NOT be static to prevent data leaks between requests in Octane' + ); + } + + /** + * Test that $uid2id is a private property. + */ + public function test_uid2id_is_private(): void + { + $reflection = new ReflectionProperty(EntityRepository::class, 'uid2id'); + + $this->assertTrue( + $reflection->isPrivate(), + 'uid2id should be private' + ); + } +} diff --git a/tests/unit/ProcessMaker/Observers/SettingObserverTest.php b/tests/unit/ProcessMaker/Observers/SettingObserverTest.php new file mode 100644 index 0000000000..612d15c3b4 --- /dev/null +++ b/tests/unit/ProcessMaker/Observers/SettingObserverTest.php @@ -0,0 +1,145 @@ +app->forgetScopedInstances(); + + $observer = app(SettingObserver::class); + $callbackCount = $this->terminatingCallbackCount(); + + $observer->saving($this->setting('first-setting')); + $observer->saving($this->setting('second-setting')); + $observer->deleted($this->setting('third-setting')); + + $this->assertSame($observer, app(SettingObserver::class)); + $this->assertSame($callbackCount + 1, $this->terminatingCallbackCount()); + } + + public function test_octane_termination_allows_a_refresh_callback_in_the_next_scope(): void + { + $this->app->forgetScopedInstances(); + + $firstObserver = app(SettingObserver::class); + $firstObserver->saving($this->setting('first-request-setting')); + $callbackCount = $this->terminatingCallbackCount(); + + (new FlushTemporaryContainerInstances())->handle(new RequestTerminated( + $this->app, + $this->app, + Request::create('/first-request'), + new Response() + )); + + $secondObserver = app(SettingObserver::class); + $secondObserver->saving($this->setting('second-request-setting')); + + $this->assertNotSame($firstObserver, $secondObserver); + $this->assertSame($callbackCount + 1, $this->terminatingCallbackCount()); + } + + public function test_eloquent_saves_updates_and_deletes_share_the_scoped_observer(): void + { + $this->app->forgetScopedInstances(); + $callbackCount = $this->terminatingCallbackCount(); + + $firstSetting = Setting::factory()->create([ + 'key' => 'four-32505-first-setting', + 'config' => 'first value', + 'format' => 'text', + ]); + Setting::factory()->create([ + 'key' => 'four-32505-second-setting', + 'config' => 'second value', + 'format' => 'text', + ]); + + $firstSetting->config = 'updated value'; + $firstSetting->save(); + $firstSetting->delete(); + + $this->assertSame($callbackCount + 1, $this->terminatingCallbackCount()); + } + + public function test_it_invalidates_every_setting_cache_entry_while_debouncing_the_refresh(): void + { + $this->app->forgetScopedInstances(); + + $observer = app(SettingObserver::class); + $settingCache = SettingCacheFactory::getSettingsCache(); + $settings = [ + $this->setting('cached-first-setting'), + $this->setting('cached-second-setting'), + $this->setting('cached-deleted-setting'), + ]; + + foreach ($settings as $setting) { + $settingCache->set($settingCache->createKey(['key' => $setting->key]), 'cached value'); + } + + $callbackCount = $this->terminatingCallbackCount(); + $observer->saving($settings[0]); + $observer->saving($settings[1]); + $observer->deleted($settings[2]); + + foreach ($settings as $setting) { + $this->assertTrue($settingCache->missing( + $settingCache->createKey(['key' => $setting->key]) + )); + } + + $this->assertSame($callbackCount + 1, $this->terminatingCallbackCount()); + } + + public function test_the_terminating_callback_dispatches_the_refresh_job_synchronously(): void + { + Bus::fake([RefreshArtisanCaches::class]); + $this->app->forgetScopedInstances(); + + $callbackCount = $this->terminatingCallbackCount(); + app(SettingObserver::class)->saving($this->setting('refresh-job-setting')); + + Bus::assertNotDispatched(RefreshArtisanCaches::class); + + $callbacks = $this->terminatingCallbacks(); + $this->app->call($callbacks[$callbackCount]); + + Bus::assertDispatchedSyncTimes(RefreshArtisanCaches::class, 1); + } + + private function setting(string $key): Setting + { + return new Setting([ + 'key' => $key, + 'config' => 'value', + 'format' => 'text', + ]); + } + + private function terminatingCallbackCount(): int + { + return count($this->terminatingCallbacks()); + } + + private function terminatingCallbacks(): array + { + return (new ReflectionProperty(Application::class, 'terminatingCallbacks')) + ->getValue($this->app); + } +} diff --git a/tests/unit/ProcessMaker/Octane/ResetRequestStateTest.php b/tests/unit/ProcessMaker/Octane/ResetRequestStateTest.php index 7895d8519f..48d701f47a 100644 --- a/tests/unit/ProcessMaker/Octane/ResetRequestStateTest.php +++ b/tests/unit/ProcessMaker/Octane/ResetRequestStateTest.php @@ -4,27 +4,71 @@ namespace Tests\Unit\ProcessMaker\Octane; +use Illuminate\Database\Events\QueryExecuted; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Laravel\Octane\Events\RequestTerminated; use ProcessMaker\Events\RedirectToEvent; +use ProcessMaker\ImportExport\Manifest; +use ProcessMaker\ImportExport\Options; use ProcessMaker\Listeners\HandleRedirectListener; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Octane\ResetRequestState; use ProcessMaker\Providers\ProcessMakerServiceProvider; +use ProcessMaker\Services\RedirectToEventService; +use ProcessMaker\Services\WorkerBootTimingService; use Symfony\Component\HttpFoundation\Response; use Tests\TestCase; class ResetRequestStateTest extends TestCase { + protected function tearDown(): void + { + ProcessMakerServiceProvider::beginRequestTiming(); + Manifest::resetRequestState(); + + parent::tearDown(); + } + + private function recordQueryDuration(float $milliseconds): void + { + $connection = DB::connection(); + + event(new QueryExecuted('SELECT 1', [], $milliseconds, $connection)); + } + + public function test_octane_request_termination_resets_manifest_request_state(): void + { + Manifest::buildParentModeMap([ + 'parent-uuid' => [ + 'dependents' => [ + ['uuid' => 'child-uuid'], + ], + ], + ], new Options([ + 'parent-uuid' => ['mode' => 'update'], + ])); + + $this->assertNotNull(Manifest::$parents); + + event(new RequestTerminated( + $this->app, + $this->app, + Request::create('/import-request'), + new Response() + )); + + $this->assertNull(Manifest::$parents); + } + public function test_it_clears_request_timing_before_the_next_request(): void { DB::select('SELECT 1'); $this->assertGreaterThan(0, ProcessMakerServiceProvider::getQueryTime()); - $listener = new ResetRequestState(); + $listener = app(ResetRequestState::class); $listener->handle(); $this->assertSame(0.0, ProcessMakerServiceProvider::getQueryTime()); @@ -37,10 +81,10 @@ public function test_it_prevents_redirect_state_from_leaking_into_the_next_reque $redirectListener = new RedirectStateProbe(); $redirectListener->queue(ProcessRequest::factory()->create()); - $listener = new ResetRequestState(); + $listener = app(ResetRequestState::class); $listener->handle(); - HandleRedirectListener::sendRedirectToEvent(); + app(RedirectToEventService::class)->sendRedirectToEvent(); Event::assertNotDispatched(RedirectToEvent::class); } @@ -49,6 +93,13 @@ public function test_octane_request_termination_automatically_resets_request_sta { Event::fake([RedirectToEvent::class]); + ProcessMakerServiceProvider::beginRequestTiming(); + $this->recordQueryDuration(5000); + + $firstRequestQueryTime = ProcessMakerServiceProvider::getQueryTime(); + + $this->assertSame(5000.0, $firstRequestQueryTime); + $redirectListener = new RedirectStateProbe(); $redirectListener->queue(ProcessRequest::factory()->create()); @@ -59,9 +110,53 @@ public function test_octane_request_termination_automatically_resets_request_sta new Response() )); - HandleRedirectListener::sendRedirectToEvent(); + $this->assertSame(0.0, ProcessMakerServiceProvider::getQueryTime()); + + app(RedirectToEventService::class)->sendRedirectToEvent(); Event::assertNotDispatched(RedirectToEvent::class); + + $this->recordQueryDuration(10); + + $nextRequestQueryTime = ProcessMakerServiceProvider::getQueryTime(); + + $this->assertSame(10.0, $nextRequestQueryTime); + } + + public function test_octane_request_termination_resets_timing_after_an_error_response(): void + { + DB::select('SELECT 1'); + + $this->assertGreaterThan(0, ProcessMakerServiceProvider::getQueryTime()); + + event(new RequestTerminated( + $this->app, + $this->app, + Request::create('/failed-request'), + new Response(status: 500) + )); + + $this->assertSame(0.0, ProcessMakerServiceProvider::getQueryTime()); + } + + public function test_octane_request_termination_preserves_worker_boot_timing(): void + { + $workerTiming = app(WorkerBootTimingService::class); + $workerTiming->setProviderBootTime(12.5); + $workerTiming->setPackageBootStart('ExamplePackage', 10.0); + $workerTiming->setPackageBootedTime('ExamplePackage', 10.25); + $expectedPackageTiming = $workerTiming->getPackageBootTiming(); + + event(new RequestTerminated( + $this->app, + $this->app, + Request::create('/first-request'), + new Response() + )); + + $this->assertSame($workerTiming, app(WorkerBootTimingService::class)); + $this->assertSame(12.5, $workerTiming->getProviderBootTime()); + $this->assertSame($expectedPackageTiming, $workerTiming->getPackageBootTiming()); } } diff --git a/tests/unit/ProcessMaker/RetryProcessRequestTest.php b/tests/unit/ProcessMaker/RetryProcessRequestTest.php new file mode 100644 index 0000000000..ec4adc89ab --- /dev/null +++ b/tests/unit/ProcessMaker/RetryProcessRequestTest.php @@ -0,0 +1,119 @@ +getProperty($property); + $propertyReflection->setAccessible(true); + + return $propertyReflection->getValue($object); + } + + private function setPrivateProperty(object $object, string $property, mixed $value): void + { + $reflection = new ReflectionClass($object); + $propertyReflection = $reflection->getProperty($property); + $propertyReflection->setAccessible(true); + $propertyReflection->setValue($object, $value); + } + + private function createRetryProcessRequest(): RetryProcessRequest + { + return RetryProcessRequest::for(ProcessRequest::factory()->create()); + } + + private function invokeDetermineTaskTypes(RetryProcessRequest $retry, bool $all = false): void + { + $reflection = new ReflectionClass($retry); + $method = $reflection->getMethod('determineTaskTypes'); + $method->setAccessible(true); + $method->invoke($retry, $all); + } + + private function withRunningInConsole(bool $runningInConsole, callable $callback): mixed + { + $originalApp = $this->app; + $mock = Mockery::mock($originalApp)->makePartial(); + $mock->shouldReceive('runningInConsole')->andReturn($runningInConsole); + $this->app = $mock; + Container::setInstance($mock); + + try { + return $callback(); + } finally { + $this->app = $originalApp; + Container::setInstance($originalApp); + } + } + + public function test_output_does_not_leak_between_instances(): void + { + $first = $this->createRetryProcessRequest(); + $second = $this->createRetryProcessRequest(); + + $this->setPrivateProperty($first, 'output', ['Retrying ScriptTask (node_1) for Request::1']); + + $this->assertSame(['Retrying ScriptTask (node_1) for Request::1'], $first->getOutput()); + $this->assertSame([], $second->getOutput()); + } + + public function test_task_types_do_not_leak_between_instances(): void + { + $first = $this->createRetryProcessRequest(); + $second = $this->createRetryProcessRequest(); + + $this->setPrivateProperty($first, 'taskTypes', ['scriptTask', 'serviceTask', 'task']); + $this->setPrivateProperty($second, 'taskTypes', ['scriptTask']); + + $this->assertSame(['scriptTask', 'serviceTask', 'task'], $this->getPrivateProperty($first, 'taskTypes')); + $this->assertSame(['scriptTask'], $this->getPrivateProperty($second, 'taskTypes')); + } + + public function test_determine_task_types_can_include_all_types_when_requested(): void + { + $retry = $this->createRetryProcessRequest(); + + $this->invokeDetermineTaskTypes($retry, true); + + $this->assertSame( + ['scriptTask', 'serviceTask', 'task'], + $this->getPrivateProperty($retry, 'taskTypes') + ); + } + + public function test_task_types_include_only_script_tasks_in_web_context(): void + { + $retry = $this->createRetryProcessRequest(); + + $this->withRunningInConsole(false, function () use ($retry) { + $this->invokeDetermineTaskTypes($retry); + }); + + $this->assertSame(['scriptTask'], $this->getPrivateProperty($retry, 'taskTypes')); + } + + public function test_has_non_retriable_tasks_does_not_leak_task_types_to_other_instances(): void + { + $first = $this->createRetryProcessRequest(); + $second = $this->createRetryProcessRequest(); + + $this->setPrivateProperty($second, 'taskTypes', ['scriptTask']); + + $first->hasNonRetriableTasks(); + + $this->assertSame(['scriptTask'], $this->getPrivateProperty($second, 'taskTypes')); + } +} diff --git a/tests/unit/ProcessMaker/Services/RedirectToEventServiceTest.php b/tests/unit/ProcessMaker/Services/RedirectToEventServiceTest.php new file mode 100644 index 0000000000..e24d3fa3c1 --- /dev/null +++ b/tests/unit/ProcessMaker/Services/RedirectToEventServiceTest.php @@ -0,0 +1,230 @@ +create(); + $secondRequest = ProcessRequest::factory()->create(); + $service = app(RedirectToEventService::class); + + $service->setRedirectTo($firstRequest, 'firstRedirect', [ + 'requestId' => $firstRequest->id, + ]); + $service->setRedirectTo($secondRequest, 'secondRedirect', [ + 'requestId' => $secondRequest->id, + ]); + + $service->sendRedirectToEvent(); + $service->sendRedirectToEvent(); + + Event::assertDispatched(RedirectToEvent::class, function (RedirectToEvent $event) use ($secondRequest) { + return $event->method === 'secondRedirect' + && $event->params[0]['requestId'] === $secondRequest->id + && $event->params['activeTokens'] === [] + && $event->broadcastOn()[0]->name === + 'private-ProcessMaker.Models.ProcessRequest.' . $secondRequest->id; + }); + Event::assertDispatched(RedirectToEvent::class, 1); + } + + public function test_scoped_binding_does_not_leak_pending_redirect_between_operations(): void + { + Event::fake([RedirectToEvent::class]); + + $firstRequest = ProcessRequest::factory()->create(); + $firstScope = app(RedirectToEventService::class); + $this->assertSame($firstScope, app(RedirectToEventService::class)); + $firstScope->setRedirectTo($firstRequest, 'staleRedirect'); + + app()->forgetScopedInstances(); + + $secondScope = app(RedirectToEventService::class); + $this->assertNotSame($firstScope, $secondScope); + + $secondScope->sendRedirectToEvent(); + Event::assertNotDispatched(RedirectToEvent::class); + + $secondRequest = ProcessRequest::factory()->create(); + $secondScope->setRedirectTo($secondRequest, 'currentRedirect', [ + 'requestId' => $secondRequest->id, + ]); + $secondScope->sendRedirectToEvent(); + + Event::assertDispatched(RedirectToEvent::class, function (RedirectToEvent $event) use ($secondRequest) { + return $event->method === 'currentRedirect' + && $event->params[0]['requestId'] === $secondRequest->id + && $event->broadcastOn()[0]->name === + 'private-ProcessMaker.Models.ProcessRequest.' . $secondRequest->id; + }); + Event::assertDispatched(RedirectToEvent::class, 1); + } + + public function test_reset_discards_pending_redirect(): void + { + Event::fake([RedirectToEvent::class]); + + $service = app(RedirectToEventService::class); + $service->setRedirectTo(ProcessRequest::factory()->create(), 'discardedRedirect'); + + $service->reset(); + $service->sendRedirectToEvent(); + + Event::assertNotDispatched(RedirectToEvent::class); + } + + public function test_activity_completed_listener_and_dispatcher_share_the_same_scoped_state(): void + { + Event::fake([RedirectToEvent::class]); + + $processRequest = ProcessRequest::factory()->create([ + 'process_collaboration_id' => null, + ]); + $activeToken = ProcessRequestToken::factory()->create([ + 'process_id' => $processRequest->process_id, + 'process_request_id' => $processRequest->id, + 'status' => 'ACTIVE', + ]); + $activeToken->setInstance($processRequest); + + app(HandleActivityCompletedRedirect::class)->handle(new ActivityCompleted($activeToken)); + app(RedirectToEventService::class)->sendRedirectToEvent(); + + Event::assertDispatched(RedirectToEvent::class, function (RedirectToEvent $event) use ( + $activeToken, + $processRequest + ) { + return $event->method === 'processUpdated' + && $event->params[0]['tokenId'] === $activeToken->id + && $event->params[0]['requestStatus'] === $processRequest->status + && $event->params['activeTokens'] === [$activeToken->id]; + }); + } + + public function test_active_tokens_exclude_closed_and_unrelated_request_tokens(): void + { + Event::fake([RedirectToEvent::class]); + + $processRequest = ProcessRequest::factory()->create([ + 'process_collaboration_id' => null, + ]); + $unrelatedRequest = ProcessRequest::factory()->create([ + 'process_collaboration_id' => null, + ]); + $activeToken = ProcessRequestToken::factory()->create([ + 'process_request_id' => $processRequest->id, + 'status' => 'ACTIVE', + ]); + $closedToken = ProcessRequestToken::factory()->create([ + 'process_request_id' => $processRequest->id, + 'status' => 'CLOSED', + ]); + $unrelatedToken = ProcessRequestToken::factory()->create([ + 'process_request_id' => $unrelatedRequest->id, + 'status' => 'ACTIVE', + ]); + + $service = app(RedirectToEventService::class); + $service->setRedirectTo($processRequest, 'isolatedRedirect'); + $service->sendRedirectToEvent(); + + Event::assertDispatched(RedirectToEvent::class, function (RedirectToEvent $event) use ( + $activeToken, + $closedToken, + $unrelatedToken + ) { + return $event->params['activeTokens'] === [$activeToken->id] + && !in_array($closedToken->id, $event->params['activeTokens'], true) + && !in_array($unrelatedToken->id, $event->params['activeTokens'], true); + }); + } + + public function test_active_tokens_include_all_active_tokens_in_the_same_collaboration(): void + { + Event::fake([RedirectToEvent::class]); + + $processRequest = ProcessRequest::factory()->create(); + $collaboratingRequest = ProcessRequest::factory()->create([ + 'process_collaboration_id' => $processRequest->process_collaboration_id, + ]); + $unrelatedRequest = ProcessRequest::factory()->create(); + $firstToken = ProcessRequestToken::factory()->create([ + 'process_request_id' => $processRequest->id, + 'status' => 'ACTIVE', + ]); + $collaboratingToken = ProcessRequestToken::factory()->create([ + 'process_request_id' => $collaboratingRequest->id, + 'status' => 'ACTIVE', + ]); + $unrelatedToken = ProcessRequestToken::factory()->create([ + 'process_request_id' => $unrelatedRequest->id, + 'status' => 'ACTIVE', + ]); + + $service = app(RedirectToEventService::class); + $service->setRedirectTo($processRequest, 'collaborationRedirect'); + $service->sendRedirectToEvent(); + + Event::assertDispatched(RedirectToEvent::class, function (RedirectToEvent $event) use ( + $firstToken, + $collaboratingToken, + $unrelatedToken + ) { + $activeTokens = $event->params['activeTokens']; + sort($activeTokens); + + $expectedTokens = [$firstToken->id, $collaboratingToken->id]; + sort($expectedTokens); + + return $activeTokens === $expectedTokens + && !in_array($unrelatedToken->id, $activeTokens, true); + }); + } + + public function test_pending_redirect_is_consumed_when_event_dispatch_throws(): void + { + $processRequest = ProcessRequest::factory()->create([ + 'process_collaboration_id' => null, + ]); + $service = app(RedirectToEventService::class); + $service->setRedirectTo($processRequest, 'failingRedirect'); + + $originalDispatcher = Event::getFacadeRoot(); + $failingDispatcher = Mockery::mock(Dispatcher::class); + $failingDispatcher->shouldReceive('dispatch') + ->once() + ->with(Mockery::type(RedirectToEvent::class)) + ->andThrow(new RuntimeException('Broadcast failed')); + Event::swap($failingDispatcher); + + try { + try { + $service->sendRedirectToEvent(); + $this->fail('The event dispatcher should have thrown an exception.'); + } catch (RuntimeException $exception) { + $this->assertSame('Broadcast failed', $exception->getMessage()); + } + + // A retry without a new redirect must not dispatch the failed event again. + $service->sendRedirectToEvent(); + } finally { + Event::swap($originalDispatcher); + } + } +} diff --git a/tests/unit/ProcessMaker/Services/WorkerBootTimingServiceTest.php b/tests/unit/ProcessMaker/Services/WorkerBootTimingServiceTest.php new file mode 100644 index 0000000000..139211139c --- /dev/null +++ b/tests/unit/ProcessMaker/Services/WorkerBootTimingServiceTest.php @@ -0,0 +1,214 @@ +setProviderBootTime(12.5); + + $this->app->forgetScopedInstances(); + + $this->assertSame($service, app(WorkerBootTimingService::class)); + $this->assertSame(12.5, app(WorkerBootTimingService::class)->getProviderBootTime()); + $this->assertNotContains(WorkerBootTimingService::class, config('octane.flush')); + } + + public function test_octane_application_clone_shares_the_worker_timing_service(): void + { + $service = app(WorkerBootTimingService::class); + $service->setProviderBootTime(18.75); + + $sandbox = clone $this->app; + $sandboxService = $sandbox->make(WorkerBootTimingService::class); + + $this->assertSame($service, $sandboxService); + $this->assertSame(18.75, $sandboxService->getProviderBootTime()); + } + + public function test_it_records_package_boot_start_and_end_times(): void + { + $service = new WorkerBootTimingService(); + + $service->setPackageBootStart('ExamplePackage', 10.25); + $service->setPackageBootedTime('ExamplePackage', 10.75); + + $this->assertSame([ + 'ExamplePackage' => [ + 'start' => 10.25, + 'end' => 10.75, + ], + ], $service->getPackageBootTiming()); + } + + public function test_repeated_package_measurements_replace_the_existing_entry(): void + { + $service = new WorkerBootTimingService(); + + $service->setPackageBootStart('ExamplePackage', 10.0); + $service->setPackageBootedTime('ExamplePackage', 11.0); + $service->setPackageBootStart('ExamplePackage', 20.0); + $service->setPackageBootedTime('ExamplePackage', 20.5); + + $this->assertCount(1, $service->getPackageBootTiming()); + $this->assertSame([ + 'start' => 20.0, + 'end' => 20.5, + ], $service->getPackageBootTiming()['ExamplePackage']); + } + + public function test_many_repeated_measurements_remain_bounded_by_unique_package_names(): void + { + $service = new WorkerBootTimingService(); + + for ($index = 0; $index < 1000; $index++) { + $package = 'Package' . ($index % 5); + $service->setPackageBootStart($package, (float) $index); + $service->setPackageBootedTime($package, $index + 0.5); + } + + $this->assertCount(5, $service->getPackageBootTiming()); + $this->assertSame([ + 'start' => 999.0, + 'end' => 999.5, + ], $service->getPackageBootTiming()['Package4']); + } + + public function test_returned_package_timing_snapshot_cannot_mutate_worker_state(): void + { + $service = new WorkerBootTimingService(); + $service->setPackageBootStart('ExamplePackage', 10.0); + $service->setPackageBootedTime('ExamplePackage', 10.5); + + $snapshot = $service->getPackageBootTiming(); + $snapshot['ExamplePackage']['start'] = 999.0; + $snapshot['InjectedPackage'] = [ + 'start' => 20.0, + 'end' => 21.0, + ]; + + $this->assertSame([ + 'ExamplePackage' => [ + 'start' => 10.0, + 'end' => 10.5, + ], + ], $service->getPackageBootTiming()); + } + + public function test_separate_worker_services_do_not_share_timing_state(): void + { + $firstWorker = new WorkerBootTimingService(); + $secondWorker = new WorkerBootTimingService(); + + $firstWorker->setProviderBootTime(10.0); + $firstWorker->setPackageBootStart('FirstWorkerPackage', 1.0); + $firstWorker->setPackageBootedTime('FirstWorkerPackage', 1.5); + + $secondWorker->setProviderBootTime(20.0); + $secondWorker->setPackageBootStart('SecondWorkerPackage', 2.0); + $secondWorker->setPackageBootedTime('SecondWorkerPackage', 2.5); + + $this->assertSame(10.0, $firstWorker->getProviderBootTime()); + $this->assertSame(20.0, $secondWorker->getProviderBootTime()); + $this->assertArrayHasKey('FirstWorkerPackage', $firstWorker->getPackageBootTiming()); + $this->assertArrayNotHasKey('SecondWorkerPackage', $firstWorker->getPackageBootTiming()); + $this->assertArrayHasKey('SecondWorkerPackage', $secondWorker->getPackageBootTiming()); + $this->assertArrayNotHasKey('FirstWorkerPackage', $secondWorker->getPackageBootTiming()); + } + + public function test_invalid_package_start_time_is_logged_and_clamped_to_zero(): void + { + Log::spy(); + $service = new WorkerBootTimingService(); + + $service->setPackageBootStart('InvalidPackage', -1.5); + + $this->assertSame([ + 'start' => 0.0, + 'end' => null, + ], $service->getPackageBootTiming()['InvalidPackage']); + Log::shouldHaveReceived('info') + ->once() + ->with('Server Timing: Invalid boot time for package: InvalidPackage, time: -1.5'); + } + + public function test_invalid_package_end_time_is_logged_and_ignored(): void + { + Log::spy(); + $service = new WorkerBootTimingService(); + $service->setPackageBootStart('InvalidPackage', 5.0); + + $service->setPackageBootedTime('InvalidPackage', -2.5); + + $this->assertNull($service->getPackageBootTiming()['InvalidPackage']['end']); + Log::shouldHaveReceived('info') + ->once() + ->with('Server Timing: Invalid booted time for package: InvalidPackage, time: -2.5'); + } + + public function test_package_end_without_a_start_is_logged_and_does_not_create_state(): void + { + Log::spy(); + $service = new WorkerBootTimingService(); + + $service->setPackageBootedTime('UnstartedPackage', 10.5); + + $this->assertSame([], $service->getPackageBootTiming()); + Log::shouldHaveReceived('info') + ->once() + ->with('Server Timing: Invalid booted time for package: UnstartedPackage, time: 10.5'); + } + + public function test_plugin_service_provider_records_one_complete_package_interval(): void + { + config(['app.server_timing.enabled' => true]); + + $this->app->register(new WorkerBootTimingTestPluginServiceProvider($this->app)); + + $timing = app(WorkerBootTimingService::class)->getPackageBootTiming(); + $this->assertArrayHasKey('WorkerBootTimingTestPlugin', $timing); + $this->assertIsFloat($timing['WorkerBootTimingTestPlugin']['start']); + $this->assertIsFloat($timing['WorkerBootTimingTestPlugin']['end']); + $this->assertGreaterThanOrEqual( + $timing['WorkerBootTimingTestPlugin']['start'], + $timing['WorkerBootTimingTestPlugin']['end'] + ); + } + + public function test_plugin_service_provider_does_not_record_timing_when_disabled(): void + { + config(['app.server_timing.enabled' => false]); + + $this->app->register(new DisabledWorkerBootTimingTestPluginServiceProvider($this->app)); + + $timing = app(WorkerBootTimingService::class)->getPackageBootTiming(); + $this->assertArrayNotHasKey('DisabledWorkerBootTimingTestPlugin', $timing); + } +} + +final class WorkerBootTimingTestPluginServiceProvider extends ServiceProvider +{ + use PluginServiceProviderTrait; + + public const name = 'worker-boot-timing-test-plugin'; + + public function boot(): void + { + usleep(1000); + } +} + +final class DisabledWorkerBootTimingTestPluginServiceProvider extends ServiceProvider +{ + use PluginServiceProviderTrait; + + public const name = 'disabled-worker-boot-timing-test-plugin'; +}