From c170c2c1d45f18de8e6b5449634f9eca8a049c3f Mon Sep 17 00:00:00 2001 From: gouater <139002105+dancycodes@users.noreply.github.com> Date: Sat, 25 Oct 2025 07:40:07 +0100 Subject: [PATCH] fix: Add middleware to convert redirects for Datastar requests Introduces ConvertRedirectsForDatastar middleware to intercept standard Laravel redirect responses for Datastar requests and convert them into Hyper-compatible SSE responses with client-side JavaScript redirects. Updates HyperRedirect to ensure session flash data persists correctly by compensating for dual session aging, and registers the middleware globally in HyperServiceProvider. Adds comprehensive unit tests for both middleware and HyperRedirect flash data handling. --- .release-please-manifest.json | 2 +- CHANGELOG.md | 9 - src/Http/HyperRedirect.php | 24 +- .../ConvertRedirectsForDatastar.php | 231 +++++++++++ src/HyperServiceProvider.php | 23 ++ tests/Unit/Http/HyperRedirectTest.php | 166 ++++++++ .../ConvertRedirectsForDatastarTest.php | 389 ++++++++++++++++++ 7 files changed, 826 insertions(+), 18 deletions(-) create mode 100644 src/Http/Middleware/ConvertRedirectsForDatastar.php create mode 100644 tests/Unit/Http/Middleware/ConvertRedirectsForDatastarTest.php diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 001eb2c..a915e8c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.2" + ".": "0.1.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 650ab0f..71cd093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,6 @@ All notable changes to `dancycodes/hyper` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.2](https://github.com/dancycodes/hyper/compare/v0.1.1...v0.1.2) (2025-10-24) - - -### Bug Fixes - -* Consider forgetting locked signals in HyperResponse forget method ([bcd8162](https://github.com/dancycodes/hyper/commit/bcd81629db06d2fd5ae6a4f53071993b599e1069)) -* Improve CSRF and error handling in hyper.js ([6908aca](https://github.com/dancycodes/hyper/commit/6908acac4a63ccb127d1c6ba8d26f83d07e28fca)) -* Update attribute syntax and refactor hyper.js internals to match Datastar RC6 ([54a8b3f](https://github.com/dancycodes/hyper/commit/54a8b3fbe661a66431a4670c6f1f91c02784d433)) - ## [0.1.1](https://github.com/dancycodes/hyper/compare/v0.1.0...v0.1.1) (2025-10-21) diff --git a/src/Http/HyperRedirect.php b/src/Http/HyperRedirect.php index 3a8fb64..cd46ce4 100644 --- a/src/Http/HyperRedirect.php +++ b/src/Http/HyperRedirect.php @@ -118,7 +118,7 @@ public function withErrors(mixed $errors): self */ public function toResponse($request = null): \Symfony\Component\HttpFoundation\StreamedResponse { - // Flash session data with guaranteed write completion + // Persist session flash data with dual-aging compensation if (!empty($this->flashData)) { if (!session()->isStarted()) { session()->start(); @@ -128,11 +128,14 @@ public function toResponse($request = null): \Symfony\Component\HttpFoundation\S session()->flash((string) $key, $value); } - // Force immediate session write and close to ensure data persists - // This prevents race conditions where redirect occurs before flash - // data is fully committed to session storage + // Persist flash data to storage, aging from _flash.new to _flash.old session()->save(); - session_write_close(); + + // Compensate for dual session save by reflashing data back to _flash.new + // StartSession middleware's terminate() will execute final save(), properly + // aging flash data for consumption by the next request. Without reflash(), + // terminate() would age data twice (_flash.old to deleted) + session()->reflash(); } $safeUrl = json_encode($this->url, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); @@ -300,7 +303,7 @@ public function intended(string $default = '/'): self */ public function forceReload(bool $forceReload = false): \Symfony\Component\HttpFoundation\StreamedResponse { - // Flash session data with guaranteed write completion + // Persist session flash data with dual-aging compensation if (!empty($this->flashData)) { if (!session()->isStarted()) { session()->start(); @@ -310,9 +313,14 @@ public function forceReload(bool $forceReload = false): \Symfony\Component\HttpF session()->flash((string) $key, $value); } - // Force immediate session write and close to ensure data persists + // Persist flash data to storage, aging from _flash.new to _flash.old session()->save(); - session_write_close(); + + // Compensate for dual session save by reflashing data back to _flash.new + // StartSession middleware's terminate() will execute final save(), properly + // aging flash data for consumption by the next request. Without reflash(), + // terminate() would age data twice (_flash.old to deleted) + session()->reflash(); } $reloadParam = $forceReload ? 'true' : 'false'; diff --git a/src/Http/Middleware/ConvertRedirectsForDatastar.php b/src/Http/Middleware/ConvertRedirectsForDatastar.php new file mode 100644 index 0000000..c48372f --- /dev/null +++ b/src/Http/Middleware/ConvertRedirectsForDatastar.php @@ -0,0 +1,231 @@ +reflash() to + * preserve flash data through Laravel's session aging mechanism, accounting for the dual + * save() calls from both response generation and StartSession middleware's terminate method. + * + * The middleware registers globally via HyperServiceProvider, activating only for requests + * with the Datastar-Request header and skipping responses already processed by HyperRedirect. + * No application-level configuration is required for operation. + * + * @see \Dancycodes\Hyper\Http\HyperRedirect + * @see \Dancycodes\Hyper\HyperServiceProvider + */ +class ConvertRedirectsForDatastar +{ + /** + * Handle an incoming request + * + * Processes responses from the application pipeline, intercepting redirect responses + * for Datastar requests and converting them to SSE responses with JavaScript navigation. + * Non-Datastar requests, non-redirect responses, and already-processed Hyper responses + * pass through unchanged. + */ + public function handle(Request $request, Closure $next): Response + { + /** @var \Symfony\Component\HttpFoundation\Response $response */ + $response = $next($request); + + // Pass through non-Datastar requests unchanged + if (!$request->hasHeader('Datastar-Request')) { + return $response; + } + + // Skip responses already processed by HyperRedirect to prevent double-conversion + if ($response->headers->has('X-Hyper-Response')) { + return $response; + } + + // Pass through non-redirect responses unchanged + if (!$this->isRedirect($response)) { + return $response; + } + + // Convert redirect to Hyper SSE response with JavaScript navigation + return $this->convertToHyperRedirect($response, $request); + } + + /** + * Determine if response represents a redirect + * + * Checks for RedirectResponse instance or HTTP status codes indicating redirects + * (201 Created with Location, 301 Moved Permanently, 302 Found, 303 See Other, + * 307 Temporary Redirect, 308 Permanent Redirect). + * + * @return bool True if response is a redirect, false otherwise + */ + protected function isRedirect(Response $response): bool + { + return $response instanceof RedirectResponse || + in_array($response->getStatusCode(), [201, 301, 302, 303, 307, 308]); + } + + /** + * Convert redirect response to Hyper SSE response with JavaScript navigation + * + * Transforms a standard HTTP redirect response into a Server-Sent Events response + * containing JavaScript that performs client-side navigation. This approach prevents + * fetch API's automatic redirect following, ensuring session flash data persists + * correctly through the navigation cycle. + * + * The method generates a JavaScript snippet using setTimeout with 200ms delay (matching + * HyperRedirect timing) to allow session persistence operations to complete before + * navigation executes. Flash data preservation is handled via session()->reflash() to + * account for Laravel's dual session save mechanism during request termination. + * + * @param \Symfony\Component\HttpFoundation\Response $response Original redirect response + * @param \Illuminate\Http\Request $request Current request instance + * + * @return \Symfony\Component\HttpFoundation\StreamedResponse SSE response with navigation script + */ + protected function convertToHyperRedirect(Response $response, Request $request): \Symfony\Component\HttpFoundation\StreamedResponse + { + $redirectUrl = $this->getRedirectUrl($response); + + // Build SSE response with JavaScript navigation script + $hyperResponse = app('hyper.response'); + $safeUrl = json_encode($redirectUrl, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); + $script = "setTimeout(() => window.location.href = {$safeUrl}, 200)"; + + /** @var \Symfony\Component\HttpFoundation\StreamedResponse $hyperStreamedResponse */ + $hyperStreamedResponse = $hyperResponse + ->js($script, ['autoRemove' => true]) + ->toResponse($request); + + // Preserve session flash data and attach session cookie to new response + $this->preserveFlashData($hyperStreamedResponse); + + // Transfer cookies from original response to maintain state (CSRF tokens, etc.) + foreach ($response->headers->getCookies() as $cookie) { + $hyperStreamedResponse->headers->setCookie($cookie); + } + + return $hyperStreamedResponse; + } + + /** + * Extract target URL from redirect response + * + * Attempts to retrieve redirect destination URL from Location header or + * RedirectResponse::getTargetUrl(). Falls back to current request URL if + * redirect target cannot be determined from response. + * + * @param \Symfony\Component\HttpFoundation\Response $response Redirect response to process + * + * @return string Target URL for navigation + */ + protected function getRedirectUrl(Response $response): string + { + // Prefer Location header as primary source for redirect URL + $location = $response->headers->get('Location'); + if ($location) { + return $location; + } + + // Extract from RedirectResponse instance if available + if ($response instanceof RedirectResponse) { + return $response->getTargetUrl(); + } + + // Fallback to current URL when redirect target cannot be determined + return request()->url(); + } + + /** + * Preserve session flash data through navigation cycle + * + * Manages session flash data persistence by utilizing Laravel's reflash() method to + * counteract the dual aging mechanism inherent in Datastar redirect handling. When + * redirect()->with() executes, it ages flash data from _flash.new to _flash.old. + * Without intervention, StartSession middleware's terminate() method would age the + * data a second time (_flash.old to deleted), rendering it unavailable to the + * destination page. + * + * The reflash() call moves flash data back to _flash.new in memory, allowing + * terminate()'s save() operation to properly age it for consumption by the next + * request. This method also manually creates and attaches the session cookie to + * the new SSE response, as the response object bypasses Laravel's standard cookie + * attachment mechanisms. + * + * @param \Symfony\Component\HttpFoundation\StreamedResponse $response SSE response requiring session cookie + */ + protected function preserveFlashData(\Symfony\Component\HttpFoundation\StreamedResponse $response): void + { + if (!session()->isStarted()) { + session()->start(); + } + + // Move flash data from _flash.old back to _flash.new to prevent double-aging + // This ensures terminate() method properly ages data for next request availability + session()->reflash(); + + // Manually attach session cookie as SSE response bypasses standard cookie handling + // StartSession middleware's terminate() will persist session data to storage + $sessionCookie = $this->createSessionCookie(); + $response->headers->setCookie($sessionCookie); + } + + /** + * Create session cookie from current session state and configuration + * + * Constructs a session cookie using Laravel's cookie() helper with configuration + * values from config/session.php. This manual cookie creation is necessary because + * the SSE response object doesn't participate in Laravel's standard session cookie + * attachment process that occurs in StartSession middleware. + * + * @return \Symfony\Component\HttpFoundation\Cookie Session cookie with current session ID + */ + protected function createSessionCookie(): \Symfony\Component\HttpFoundation\Cookie + { + /** @var array $config */ + $config = config('session', []); + + $name = is_string($config['cookie'] ?? null) && $config['cookie'] !== '' ? $config['cookie'] : 'laravel_session'; + $lifetime = is_int($config['lifetime'] ?? null) ? $config['lifetime'] : 120; + $path = is_string($config['path'] ?? null) ? $config['path'] : '/'; + $domain = is_string($config['domain'] ?? null) ? $config['domain'] : null; + $secure = is_bool($config['secure'] ?? null) ? $config['secure'] : false; + $httpOnly = is_bool($config['http_only'] ?? null) ? $config['http_only'] : true; + $sameSite = is_string($config['same_site'] ?? null) ? $config['same_site'] : 'lax'; + + return cookie( + $name, + session()->getId(), + $lifetime, + $path, + $domain, + $secure, + $httpOnly, + false, + $sameSite + ); + } +} diff --git a/src/HyperServiceProvider.php b/src/HyperServiceProvider.php index 9f122bc..3ec5ab6 100644 --- a/src/HyperServiceProvider.php +++ b/src/HyperServiceProvider.php @@ -120,6 +120,7 @@ public function boot(): void $this->registerBase64ValidationRules(); $this->registerRouteDiscovery(); $this->registerSignalsDirective(); + $this->registerRedirectConversionMiddleware(); } /** @@ -562,4 +563,26 @@ protected function registerSignalsDirective(): void return "render({$rewrittenExpression}); ?>"; }); } + + /** + * Register middleware to automatically convert redirects for Datastar requests + * + * Registers the ConvertRedirectsForDatastar middleware globally to intercept + * standard Laravel redirect responses and convert them into Hyper-style SSE + * responses for Datastar requests. This prevents the double-navigation issue + * where flash data is consumed during fetch API's automatic redirect following. + * + * This middleware runs for ALL requests but only modifies responses for + * Datastar requests that result in redirects, ensuring zero impact on normal + * HTTP request/response cycles. + */ + protected function registerRedirectConversionMiddleware(): void + { + /** @var \Illuminate\Contracts\Http\Kernel $kernel */ + $kernel = $this->app->make(\Illuminate\Contracts\Http\Kernel::class); + + // Push middleware to the global middleware stack + // This ensures it runs for all requests and can intercept responses + $kernel->pushMiddleware(\Dancycodes\Hyper\Http\Middleware\ConvertRedirectsForDatastar::class); + } } diff --git a/tests/Unit/Http/HyperRedirectTest.php b/tests/Unit/Http/HyperRedirectTest.php index 96e60f2..e188b64 100644 --- a/tests/Unit/Http/HyperRedirectTest.php +++ b/tests/Unit/Http/HyperRedirectTest.php @@ -537,4 +537,170 @@ public function test_force_reload_method_reloads_page() $this->assertTrue($hasReloadScript, 'Should generate window.location.reload script'); } + + /** @test */ + public function test_with_method_sets_flash_data() + { + $this->setupHyperRequest(); + + $hyperResponse = app(HyperResponse::class); + $redirect = new HyperRedirect('/dashboard', $hyperResponse); + + // Set flash data + $redirect->with('success', 'Operation completed successfully'); + + $response = $redirect->toResponse(request()); + + // Verify flash data was set and reflashed + $this->assertNotNull(session()->get('_flash.new')); + $this->assertContains('success', session()->get('_flash.new')); + } + + /** @test */ + public function test_with_method_sets_multiple_flash_data() + { + $this->setupHyperRequest(); + + $hyperResponse = app(HyperResponse::class); + $redirect = new HyperRedirect('/dashboard', $hyperResponse); + + // Set multiple flash data + $redirect->with([ + 'success' => 'Item created', + 'item_id' => 123, + 'metadata' => ['count' => 5], + ]); + + $response = $redirect->toResponse(request()); + + // Verify all flash data was set + $flashNew = session()->get('_flash.new', []); + $this->assertContains('success', $flashNew); + $this->assertContains('item_id', $flashNew); + $this->assertContains('metadata', $flashNew); + } + + /** @test */ + public function test_flash_data_is_reflashed_after_save() + { + $this->setupHyperRequest(); + + $hyperResponse = app(HyperResponse::class); + $redirect = new HyperRedirect('/dashboard', $hyperResponse); + + $redirect->with('test', 'value'); + + // Call toResponse which will flash, save, and reflash + $response = $redirect->toResponse(request()); + + // After toResponse with reflash(), flash data should be in new (reflashed after save) + $flashAfter = session()->get('_flash.new', []); + $this->assertContains('test', $flashAfter); + + // Verify the actual value is still accessible + $this->assertEquals('value', session()->get('test')); + } + + /** @test */ + public function test_force_reload_preserves_flash_data() + { + $this->setupHyperRequest(); + + $hyperResponse = app(HyperResponse::class); + $redirect = new HyperRedirect('/ignored', $hyperResponse); + + $redirect->with('reload_message', 'Page will reload with this message'); + + $response = $redirect->forceReload(true); + + // Verify flash data was reflashed + $flashNew = session()->get('_flash.new', []); + $this->assertContains('reload_message', $flashNew); + } + + /** @test */ + public function test_session_save_is_called_before_reflash() + { + $this->setupHyperRequest(); + + $hyperResponse = app(HyperResponse::class); + $redirect = new HyperRedirect('/target', $hyperResponse); + + $redirect->with('test_key', 'test_value'); + + // Capture session state before toResponse + $sessionIdBefore = session()->getId(); + + $response = $redirect->toResponse(request()); + + // Session should still be active (not closed) with same ID + $this->assertEquals($sessionIdBefore, session()->getId()); + + // Flash data should be in _flash.new after reflash + $this->assertContains('test_key', session()->get('_flash.new', [])); + } + + /** @test */ + public function test_empty_flash_data_does_not_call_session_operations() + { + $this->setupHyperRequest(); + + $hyperResponse = app(HyperResponse::class); + $redirect = new HyperRedirect('/target', $hyperResponse); + + // Don't set any flash data + $response = $redirect->toResponse(request()); + + // Should still generate valid SSE response + $this->assertInstanceOf(\Symfony\Component\HttpFoundation\StreamedResponse::class, $response); + } + + /** @test */ + public function test_flash_data_persists_through_dual_save_cycle() + { + $this->setupHyperRequest(); + + $hyperResponse = app(HyperResponse::class); + $redirect = new HyperRedirect('/dashboard', $hyperResponse); + + $redirect->with('message', 'Test persistence'); + + // First save happens in toResponse + $response = $redirect->toResponse(request()); + + // Verify data is in _flash.new (reflashed after save) + $flashKeys = session()->get('_flash.new', []); + $this->assertContains('message', $flashKeys); + + // Simulate StartSession middleware's terminate() save + session()->save(); + + // After terminate's save, data should be in _flash.old (aged correctly) + $flashOldKeys = session()->get('_flash.old', []); + $this->assertContains('message', $flashOldKeys); + + // And the actual value should still be accessible + $this->assertEquals('Test persistence', session()->get('message')); + } + + /** @test */ + public function test_multiple_with_calls_accumulate_flash_data() + { + $this->setupHyperRequest(); + + $hyperResponse = app(HyperResponse::class); + $redirect = new HyperRedirect('/target', $hyperResponse); + + $redirect + ->with('first', 'value1') + ->with('second', 'value2') + ->with('third', 'value3'); + + $response = $redirect->toResponse(request()); + + $flashKeys = session()->get('_flash.new', []); + $this->assertContains('first', $flashKeys); + $this->assertContains('second', $flashKeys); + $this->assertContains('third', $flashKeys); + } } diff --git a/tests/Unit/Http/Middleware/ConvertRedirectsForDatastarTest.php b/tests/Unit/Http/Middleware/ConvertRedirectsForDatastarTest.php new file mode 100644 index 0000000..e79b898 --- /dev/null +++ b/tests/Unit/Http/Middleware/ConvertRedirectsForDatastarTest.php @@ -0,0 +1,389 @@ +start(); + + // Set up Hyper request for middleware tests + request()->headers->set('Datastar-Request', 'true'); + } + + protected function tearDown(): void + { + // Clean up session after each test + session()->flush(); + + parent::tearDown(); + } + + /** + * @test + */ + public function it_passes_through_non_datastar_requests() + { + // Remove Datastar header for this specific test + request()->headers->remove('Datastar-Request'); + + $middleware = new ConvertRedirectsForDatastar; + $response = new RedirectResponse('/target'); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + $this->assertSame($response, $result); + $this->assertInstanceOf(RedirectResponse::class, $result); + } + + /** + * @test + */ + public function it_passes_through_non_redirect_responses_for_datastar_requests() + { + $middleware = new ConvertRedirectsForDatastar; + $response = new Response('
Content
', 200); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + $this->assertSame($response, $result); + $this->assertInstanceOf(Response::class, $result); + } + + /** + * @test + */ + public function it_converts_redirect_response_to_sse_for_datastar_requests() + { + $middleware = new ConvertRedirectsForDatastar; + $response = new RedirectResponse('/target'); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + $this->assertInstanceOf(StreamedResponse::class, $result); + $this->assertEquals('text/event-stream', $result->headers->get('Content-Type')); + // Cache-Control header may include 'private' in addition to 'no-cache' (Laravel default) + $this->assertStringContainsString('no-cache', $result->headers->get('Cache-Control')); + } + + /** + * @test + */ + public function it_skips_responses_already_processed_by_hyper_redirect() + { + $middleware = new ConvertRedirectsForDatastar; + + $response = new StreamedResponse; + $response->headers->set('X-Hyper-Response', 'true'); + $response->setStatusCode(302); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + $this->assertSame($response, $result); + } + + /** + * @test + */ + public function it_detects_redirect_response_instance() + { + $middleware = new ConvertRedirectsForDatastar; + $response = new RedirectResponse('/target'); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + $this->assertInstanceOf(StreamedResponse::class, $result); + } + + /** + * @test + * + * @dataProvider redirectStatusCodesProvider + */ + public function it_detects_redirect_status_codes($statusCode) + { + $middleware = new ConvertRedirectsForDatastar; + + $response = new Response('', $statusCode); + $response->headers->set('Location', '/target'); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + $this->assertInstanceOf(StreamedResponse::class, $result); + } + + public static function redirectStatusCodesProvider(): array + { + return [ + '201 Created' => [201], + '301 Moved Permanently' => [301], + '302 Found' => [302], + '303 See Other' => [303], + '307 Temporary Redirect' => [307], + '308 Permanent Redirect' => [308], + ]; + } + + /** + * @test + */ + public function it_generates_javascript_navigation_script() + { + $middleware = new ConvertRedirectsForDatastar; + $response = new RedirectResponse('/dashboard'); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + $events = $this->getSSEEvents($result); + + // Verify we have events + $this->assertGreaterThan(0, count($events), 'Should have at least one SSE event'); + + // Verify JavaScript includes the target URL and setTimeout + $hasWindowLocation = false; + $hasTargetUrl = false; + $hasSetTimeout = false; + + foreach ($events as $event) { + if (isset($event['data'])) { + if (str_contains($event['data'], 'window.location')) { + $hasWindowLocation = true; + } + if (str_contains($event['data'], '/dashboard')) { + $hasTargetUrl = true; + } + if (str_contains($event['data'], 'setTimeout')) { + $hasSetTimeout = true; + } + } + } + + $this->assertTrue($hasWindowLocation, 'JavaScript should include window.location'); + $this->assertTrue($hasTargetUrl, 'JavaScript should include target URL /dashboard'); + $this->assertTrue($hasSetTimeout, 'JavaScript should include setTimeout'); + } + + /** + * @test + */ + public function it_uses_200ms_delay_for_navigation() + { + $middleware = new ConvertRedirectsForDatastar; + $response = new RedirectResponse('/target'); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + $events = $this->getSSEEvents($result); + + // Verify 200ms delay is used in setTimeout + $has200msDelay = false; + foreach ($events as $event) { + if (isset($event['data']) && str_contains($event['data'], ', 200)')) { + $has200msDelay = true; + break; + } + } + + $this->assertTrue($has200msDelay, 'Navigation script should use 200ms delay'); + } + + /** + * @test + */ + public function it_escapes_url_in_javascript() + { + $middleware = new ConvertRedirectsForDatastar; + $response = new RedirectResponse('/search?q=test&category=books'); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + // Verify response is a StreamedResponse with SSE content + $this->assertInstanceOf(StreamedResponse::class, $result); + + $events = $this->getSSEEvents($result); + $this->assertGreaterThan(0, count($events), 'Should have at least one SSE event'); + + // Verify URL components are present in the JavaScript + $hasUrl = false; + foreach ($events as $event) { + if (isset($event['data']) && + (str_contains($event['data'], 'search') || str_contains($event['data'], '/search')) && + str_contains($event['data'], 'test') && + str_contains($event['data'], 'books')) { + $hasUrl = true; + break; + } + } + + $this->assertTrue($hasUrl, 'URL with query parameters should be in the navigation script'); + } + + /** + * @test + */ + public function it_extracts_url_from_location_header() + { + $middleware = new ConvertRedirectsForDatastar; + + $response = new Response('', 302); + $response->headers->set('Location', '/custom-target'); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + // Verify response is a StreamedResponse + $this->assertInstanceOf(StreamedResponse::class, $result); + + $events = $this->getSSEEvents($result); + $this->assertGreaterThan(0, count($events), 'Should have at least one SSE event'); + + // Verify custom target URL is in the script + $hasCustomTarget = false; + foreach ($events as $event) { + if (isset($event['data']) && + (str_contains($event['data'], 'custom-target') || str_contains($event['data'], '/custom-target'))) { + $hasCustomTarget = true; + break; + } + } + + $this->assertTrue($hasCustomTarget, 'Custom target URL from Location header should be in the script'); + } + + /** + * @test + */ + public function it_preserves_session_flash_data_through_redirect() + { + $middleware = new ConvertRedirectsForDatastar; + + // Set flash data like redirect()->with() would do + session()->flash('success', 'Operation completed'); + + $response = new RedirectResponse('/dashboard'); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + // Verify reflash was called (flash data moved from old to new) + $this->assertNotNull(session()->get('_flash.new')); + $this->assertContains('success', session()->get('_flash.new')); + } + + /** + * @test + */ + public function it_attaches_session_cookie_to_response() + { + $middleware = new ConvertRedirectsForDatastar; + + session()->flash('test', 'value'); + + $response = new RedirectResponse('/target'); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + $cookies = $result->headers->getCookies(); + + $this->assertNotEmpty($cookies); + + $sessionCookie = collect($cookies)->first(function ($cookie) { + return $cookie->getName() === config('session.cookie'); + }); + + $this->assertNotNull($sessionCookie); + $this->assertEquals(session()->getId(), $sessionCookie->getValue()); + } + + /** + * @test + */ + public function it_copies_cookies_from_original_response() + { + $middleware = new ConvertRedirectsForDatastar; + + $response = new RedirectResponse('/target'); + $response->headers->setCookie(cookie('custom_cookie', 'test_value')); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + $cookies = $result->headers->getCookies(); + + $customCookie = collect($cookies)->first(function ($cookie) { + return $cookie->getName() === 'custom_cookie'; + }); + + $this->assertNotNull($customCookie); + $this->assertEquals('test_value', $customCookie->getValue()); + } + + /** + * @test + */ + public function it_creates_session_cookie_with_correct_configuration() + { + config(['session.cookie' => 'test_session']); + config(['session.lifetime' => 240]); + config(['session.path' => '/app']); + config(['session.domain' => 'example.com']); + config(['session.secure' => true]); + config(['session.http_only' => true]); + config(['session.same_site' => 'strict']); + + $middleware = new ConvertRedirectsForDatastar; + + $response = new RedirectResponse('/target'); + + $result = $middleware->handle(request(), function () use ($response) { + return $response; + }); + + $cookies = $result->headers->getCookies(); + + $sessionCookie = collect($cookies)->first(function ($cookie) { + return $cookie->getName() === 'test_session'; + }); + + $this->assertNotNull($sessionCookie); + $this->assertEquals('/app', $sessionCookie->getPath()); + $this->assertEquals('example.com', $sessionCookie->getDomain()); + $this->assertTrue($sessionCookie->isSecure()); + $this->assertTrue($sessionCookie->isHttpOnly()); + $this->assertEquals('strict', $sessionCookie->getSameSite()); + } +}