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()); + } +}