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 2d2850fad8..490235af17 100644 --- a/ProcessMaker/Listeners/HandleRedirectListener.php +++ b/ProcessMaker/Listeners/HandleRedirectListener.php @@ -2,49 +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::reset(); - } + $this->redirectToEventService ??= app(RedirectToEventService::class); + $this->redirectToEventService->setRedirectTo($processRequest, $method, ...$params); } } diff --git a/ProcessMaker/Octane/ResetRequestState.php b/ProcessMaker/Octane/ResetRequestState.php index 45071e97ad..8bde301049 100644 --- a/ProcessMaker/Octane/ResetRequestState.php +++ b/ProcessMaker/Octane/ResetRequestState.php @@ -4,14 +4,19 @@ namespace ProcessMaker\Octane; -use ProcessMaker\Listeners\HandleRedirectListener; 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(); + $this->redirectToEventService->reset(); } } diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index 55ef2cfa55..be396eb00d 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -56,6 +56,7 @@ use ProcessMaker\Providers\PermissionServiceProvider; use ProcessMaker\Repositories\SettingsConfigRepository; use ProcessMaker\Services\ConditionalRedirectService; +use ProcessMaker\Services\RedirectToEventService; use RuntimeException; use Spatie\Multitenancy\Events\MadeTenantCurrentEvent; use Spatie\Multitenancy\Events\TenantNotFoundForRequestEvent; @@ -249,6 +250,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. 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/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 index 0e3e86cf4e..9f6ff1ed13 100644 --- a/tests/unit/ProcessMaker/Listeners/HandleRedirectListenerTest.php +++ b/tests/unit/ProcessMaker/Listeners/HandleRedirectListenerTest.php @@ -4,21 +4,28 @@ namespace Tests\Unit\ProcessMaker\Listeners; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Event; +use Laravel\Octane\Events\RequestTerminated; +use Mockery; use ProcessMaker\Events\RedirectToEvent; use ProcessMaker\Listeners\HandleRedirectListener; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Octane\ResetRequestState; -use ReflectionProperty; +use ProcessMaker\Services\RedirectToEventService; +use Symfony\Component\HttpFoundation\Response; use Tests\TestCase; class HandleRedirectListenerTest extends TestCase { /** - * Create a test subclass that exposes the protected setRedirectTo method. + * Create a test listener that exposes the protected setRedirectTo method. */ - private function createProbe(): HandleRedirectListener + private function createProbe(?RedirectToEventService $service = null): HandleRedirectListener { - return new class extends HandleRedirectListener { + $service ??= app(RedirectToEventService::class); + + return new class ($service) extends HandleRedirectListener { public function queue(ProcessRequest $processRequest, string $method, ...$params): void { $this->setRedirectTo($processRequest, $method, ...$params); @@ -26,210 +33,180 @@ public function queue(ProcessRequest $processRequest, string $method, ...$params }; } - /** - * Read a private static property from HandleRedirectListener. - */ - private function readStaticProperty(string $property): mixed - { - $reflection = new ReflectionProperty(HandleRedirectListener::class, $property); - $reflection->setAccessible(true); - - return $reflection->getValue(); - } - - /** - * Assert that all 3 static properties are in their default/clean state. - */ - private function assertStateIsClean(): void - { - $this->assertNull($this->readStaticProperty('processRequest')); - $this->assertSame('', $this->readStaticProperty('redirectionMethod')); - $this->assertSame([], $this->readStaticProperty('redirectionParams')); - } - - /** - * Test that reset() clears the static $processRequest property. - */ public function test_reset_clears_process_request(): void { - $probe = $this->createProbe(); - $request = ProcessRequest::factory()->create(); - $probe->queue($request, 'processUpdated'); - - HandleRedirectListener::reset(); - - $this->assertNull($this->readStaticProperty('processRequest')); + 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); } - /** - * Test that reset() clears the static $redirectionMethod property. - */ public function test_reset_clears_redirection_method(): void { - $probe = $this->createProbe(); + Event::fake([RedirectToEvent::class]); + + $service = app(RedirectToEventService::class); + $probe = $this->createProbe($service); $request = ProcessRequest::factory()->create(); - $probe->queue($request, 'processCompletedRedirect'); - HandleRedirectListener::reset(); + $probe->queue($request, 'staleRedirect'); + $service->reset(); + $probe->queue($request, 'currentRedirect'); + $service->sendRedirectToEvent(); - $this->assertSame('', $this->readStaticProperty('redirectionMethod')); + Event::assertDispatched( + RedirectToEvent::class, + fn (RedirectToEvent $event) => $event->method === 'currentRedirect' + ); } - /** - * Test that reset() clears the static $redirectionParams property. - */ public function test_reset_clears_redirection_params(): void { - $probe = $this->createProbe(); + Event::fake([RedirectToEvent::class]); + + $service = app(RedirectToEventService::class); + $probe = $this->createProbe($service); $request = ProcessRequest::factory()->create(); - $probe->queue($request, 'processUpdated', ['key' => 'value']); - HandleRedirectListener::reset(); + $probe->queue($request, 'processUpdated', ['secret' => 'stale']); + $service->reset(); + $probe->queue($request, 'processUpdated', ['tokenId' => 222]); + $service->sendRedirectToEvent(); - $this->assertSame([], $this->readStaticProperty('redirectionParams')); + Event::assertDispatched(RedirectToEvent::class, function (RedirectToEvent $event) { + return $event->params[0] === ['tokenId' => 222] + && !array_key_exists('secret', $event->params[0]); + }); } - /** - * Critical test for Octane: verify that reset() prevents data leaks. - * After reset(), the stored redirect data should be gone. - */ public function test_reset_prevents_stale_redirect_from_leaking(): void { - $probe = $this->createProbe(); - $request = ProcessRequest::factory()->create(); - $probe->queue($request, 'processUpdated', ['tokenId' => 123]); + Event::fake([RedirectToEvent::class]); + + $service = app(RedirectToEventService::class); + $this->createProbe($service)->queue( + ProcessRequest::factory()->create(), + 'processUpdated', + ['tokenId' => 123] + ); - // Simulate Octane reset between requests - HandleRedirectListener::reset(); + $service->reset(); + $service->sendRedirectToEvent(); - // sendRedirectToEvent should NOT dispatch RedirectToEvent after reset - $this->expectNotToPerformAssertions(); - HandleRedirectListener::sendRedirectToEvent(); + Event::assertNotDispatched(RedirectToEvent::class); } - /** - * Test that reset() can be called multiple times safely. - */ public function test_reset_can_be_called_multiple_times(): void { - HandleRedirectListener::reset(); - HandleRedirectListener::reset(); - HandleRedirectListener::reset(); + Event::fake([RedirectToEvent::class]); - // Should not throw any errors - $this->assertStateIsClean(); + $service = app(RedirectToEventService::class); + $this->createProbe($service)->queue(ProcessRequest::factory()->create(), 'processUpdated'); + + $service->reset(); + $service->reset(); + $service->reset(); + $service->sendRedirectToEvent(); + + Event::assertNotDispatched(RedirectToEvent::class); } - /** - * Test that sendRedirectToEvent dispatches the event and clears state. - */ public function test_send_redirect_to_event_dispatches_and_clears_state(): void { - \Illuminate\Support\Facades\Event::fake([RedirectToEvent::class]); - - $probe = $this->createProbe(); - $request = ProcessRequest::factory()->create(); - $probe->queue($request, 'processUpdated'); + Event::fake([RedirectToEvent::class]); - HandleRedirectListener::sendRedirectToEvent(); + $service = app(RedirectToEventService::class); + $this->createProbe($service)->queue(ProcessRequest::factory()->create(), 'processUpdated'); - // Assert the event was dispatched - \Illuminate\Support\Facades\Event::assertDispatched(RedirectToEvent::class); + $service->sendRedirectToEvent(); + $service->sendRedirectToEvent(); - // After dispatch, the state should be cleared - $this->assertNull($this->readStaticProperty('processRequest')); + Event::assertDispatched( + RedirectToEvent::class, + fn (RedirectToEvent $event) => $event->method === 'processUpdated' + ); + Event::assertDispatched(RedirectToEvent::class, 1); } - /** - * CRITICAL: Simulate the full Octane request cycle to guarantee no data leak. - * - * Flow: - * 1. Request A stores data with different values - * 2. Reset (simulating Octane's RequestTerminated event) - * 3. Verify ALL 3 properties are clean - * 4. Request B stores NEW data with different values - * 5. Verify Request B's data is correct (not contaminated by Request A) - * 6. Reset again - * 7. Verify clean again - */ public function test_full_octane_cycle_guarantees_no_data_leak(): void { - // === Request A === + Event::fake([RedirectToEvent::class]); + $requestA = ProcessRequest::factory()->create(); - $probeA = $this->createProbe(); - $probeA->queue($requestA, 'processCompletedRedirect', ['tokenA' => 111]); + $scopeA = app(RedirectToEventService::class); + $this->createProbe($scopeA)->queue( + $requestA, + 'processCompletedRedirect', + ['tokenA' => 111] + ); - // Verify Request A data is stored (setRedirectTo uses ...$params, so it's nested) - $this->assertSame($requestA->getKey(), $this->readStaticProperty('processRequest')->getKey()); - $this->assertSame('processCompletedRedirect', $this->readStaticProperty('redirectionMethod')); - $this->assertSame([['tokenA' => 111]], $this->readStaticProperty('redirectionParams')); + app(ResetRequestState::class)->handle(); + $scopeA->sendRedirectToEvent(); + Event::assertNotDispatched(RedirectToEvent::class); - // === Octane reset after Request A === - HandleRedirectListener::reset(); + app()->forgetScopedInstances(); - // === Verify ALL properties are clean after reset === - $this->assertStateIsClean(); + $scopeB = app(RedirectToEventService::class); + $this->assertNotSame($scopeA, $scopeB); - // === Request B (simulating a DIFFERENT user/request) === $requestB = ProcessRequest::factory()->create(); - $probeB = $this->createProbe(); - $probeB->queue($requestB, 'processUpdated', ['tokenB' => 222, 'userId' => 999]); - - // Verify Request B's data is correct (NOT contaminated by Request A) - $this->assertSame($requestB->getKey(), $this->readStaticProperty('processRequest')->getKey()); - $this->assertSame('processUpdated', $this->readStaticProperty('redirectionMethod')); - $this->assertSame([['tokenB' => 222, 'userId' => 999]], $this->readStaticProperty('redirectionParams')); - - // Verify Request A's data is GONE (no leak) - $this->assertNotSame($requestA->getKey(), $this->readStaticProperty('processRequest')?->getKey()); - - // === Octane reset after Request B === - HandleRedirectListener::reset(); - - // === Verify clean again === - $this->assertStateIsClean(); + $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); } - /** - * CRITICAL: Verify that ResetRequestState orchestrator triggers the reset correctly. - */ - public function test_reset_request_state_triggers_handle_redirect_reset(): void + public function test_reset_request_state_triggers_redirect_service_reset(): void { - $probe = $this->createProbe(); - $request = ProcessRequest::factory()->create(); - $probe->queue($request, 'processUpdated', ['tokenId' => 456]); - - // Verify state is dirty before reset - $this->assertNotNull($this->readStaticProperty('processRequest')); - - // Execute the orchestrator (same as Octane's RequestTerminated listener) - $resetState = new ResetRequestState(); - $resetState->handle(); + $service = Mockery::mock(RedirectToEventService::class); + $service->shouldReceive('reset')->once(); - // Verify orchestrator cleaned everything - $this->assertStateIsClean(); + (new ResetRequestState($service))->handle(); } - /** - * CRITICAL: Simulate the scenario where sendRedirectToEvent() fails, - * but reset() still cleans up (edge case in Octane). - */ - public function test_reset_cleans_up_even_when_send_redirect_fails(): void + public function test_octane_termination_cleans_up_when_redirect_is_never_sent(): void { - $probe = $this->createProbe(); - $request = ProcessRequest::factory()->create(); - $probe->queue($request, 'processUpdated', ['data' => 'sensitive']); + Event::fake([RedirectToEvent::class]); - // Simulate that sendRedirectToEvent is NEVER called (e.g., error in BPMN flow) - // But Octane's RequestTerminated event still fires and calls reset() + $service = app(RedirectToEventService::class); + $this->createProbe($service)->queue( + ProcessRequest::factory()->create(), + 'processUpdated', + ['data' => 'sensitive'] + ); - // This should NOT be called in this scenario: - // HandleRedirectListener::sendRedirectToEvent(); + event(new RequestTerminated( + $this->app, + $this->app, + Request::create('/first-request'), + new Response() + )); - // Octane reset still happens - HandleRedirectListener::reset(); + app(RedirectToEventService::class)->sendRedirectToEvent(); - // Verify no sensitive data leaked - $this->assertStateIsClean(); + Event::assertNotDispatched(RedirectToEvent::class); } } diff --git a/tests/unit/ProcessMaker/Octane/ResetRequestStateTest.php b/tests/unit/ProcessMaker/Octane/ResetRequestStateTest.php index 7895d8519f..1ec4a124aa 100644 --- a/tests/unit/ProcessMaker/Octane/ResetRequestStateTest.php +++ b/tests/unit/ProcessMaker/Octane/ResetRequestStateTest.php @@ -13,6 +13,7 @@ use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Octane\ResetRequestState; use ProcessMaker\Providers\ProcessMakerServiceProvider; +use ProcessMaker\Services\RedirectToEventService; use Symfony\Component\HttpFoundation\Response; use Tests\TestCase; @@ -24,7 +25,7 @@ public function test_it_clears_request_timing_before_the_next_request(): void $this->assertGreaterThan(0, ProcessMakerServiceProvider::getQueryTime()); - $listener = new ResetRequestState(); + $listener = app(ResetRequestState::class); $listener->handle(); $this->assertSame(0.0, ProcessMakerServiceProvider::getQueryTime()); @@ -37,10 +38,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); } @@ -59,7 +60,7 @@ public function test_octane_request_termination_automatically_resets_request_sta new Response() )); - HandleRedirectListener::sendRedirectToEvent(); + app(RedirectToEventService::class)->sendRedirectToEvent(); Event::assertNotDispatched(RedirectToEvent::class); } 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); + } + } +}