diff --git a/composer.json b/composer.json index eefd8e8d8..59ada898f 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ "Hypervel\\Hashing\\": "src/hashing/src/", "Hypervel\\Horizon\\": "src/horizon/src/", "Hypervel\\Http\\": "src/http/src/", + "Hypervel\\Inertia\\": "src/inertia/src/", "Hypervel\\JsonSchema\\": "src/json-schema/src/", "Hypervel\\JWT\\": "src/jwt/src/", "Hypervel\\Log\\": "src/log/src/", @@ -106,6 +107,7 @@ "src/collections/src/helpers.php", "src/support/src/functions.php", "src/support/src/helpers.php", + "src/inertia/src/helpers.php", "src/testbench/src/functions.php" ] }, @@ -213,6 +215,7 @@ "hypervel/hashing": "self.version", "hypervel/horizon": "self.version", "hypervel/http": "self.version", + "hypervel/inertia": "self.version", "hypervel/jwt": "self.version", "hypervel/log": "self.version", "hypervel/macroable": "self.version", @@ -299,6 +302,7 @@ "Hypervel\\Filesystem\\FilesystemServiceProvider", "Hypervel\\Hashing\\HashingServiceProvider", "Hypervel\\Http\\HttpServiceProvider", + "Hypervel\\Inertia\\InertiaServiceProvider", "Hypervel\\JWT\\JWTServiceProvider", "Hypervel\\Log\\Context\\ContextServiceProvider", "Hypervel\\Log\\LogServiceProvider", diff --git a/docs/ai/porting.md b/docs/ai/porting.md index 6b5d55540..7ecf66f04 100644 --- a/docs/ai/porting.md +++ b/docs/ai/porting.md @@ -540,6 +540,10 @@ All tests run inside coroutines by default. The `RunTestsInCoroutine` trait is o These are primarily useful for DB operations or external service setup that needs coroutine context. Most ported Laravel tests won't need them. +#### Request Context in Tests + +`request()` resolves from `RequestContext` — when no request exists in context (tests that don't make HTTP requests), each `request()` call creates a throwaway fallback instance. This means `request()->merge()` has no effect on subsequent `request()` calls. Replace `request()->merge(['key' => 'value'])` with `RequestContext::set(Request::create('/?key=value'))` to seed a stable request in context. + #### Static State and Test Cleanup `AfterEachTestSubscriber` handles global static state cleanup between tests. It calls `flushState()` on framework classes that accumulate static state (Mockery, HandleExceptions, Carbon, Number, Eloquent Model, Paginator, etc.). **Never add cleanup for these in `tearDown()`** — it's already handled. diff --git a/src/contracts/src/Http/Kernel.php b/src/contracts/src/Http/Kernel.php index 1eeae3a68..9ca6453aa 100644 --- a/src/contracts/src/Http/Kernel.php +++ b/src/contracts/src/Http/Kernel.php @@ -25,6 +25,13 @@ public function handle(Request $request): Response; */ public function terminate(Request $request, Response $response): void; + /** + * Get the application's route middleware groups. + * + * @return array> + */ + public function getMiddlewareGroups(): array; + /** * Get the application instance. */ diff --git a/src/http/src/Client/Response.php b/src/http/src/Client/Response.php index 5be8af688..b9a247449 100644 --- a/src/http/src/Client/Response.php +++ b/src/http/src/Client/Response.php @@ -31,7 +31,12 @@ class Response implements ArrayAccess, Stringable /** * The decoded JSON response. */ - protected array $decoded = []; + protected mixed $decoded = null; + + /** + * Whether the response body has been decoded. + */ + protected bool $hasDecoded = false; /** * The custom decode callback. @@ -73,8 +78,9 @@ public function body(): string */ public function json(?string $key = null, mixed $default = null): mixed { - if (! $this->decoded) { + if (! $this->hasDecoded) { $this->decoded = $this->decode($this->body()); + $this->hasDecoded = true; } if (is_null($key)) { @@ -111,7 +117,8 @@ public function object(): array|object|null public function decodeUsing(?Closure $callback): static { $this->decodeUsing = $callback; - $this->decoded = []; + $this->decoded = null; + $this->hasDecoded = false; return $this; } @@ -119,7 +126,7 @@ public function decodeUsing(?Closure $callback): static /** * Decode the given response body. */ - protected function decode(string $body, bool $asObject = false): array|object|null + protected function decode(string $body, bool $asObject = false): mixed { if ($this->decodeUsing instanceof Closure) { return ($this->decodeUsing)($body, $asObject); diff --git a/src/inertia/LICENSE.md b/src/inertia/LICENSE.md new file mode 100644 index 000000000..925a9ddeb --- /dev/null +++ b/src/inertia/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Jonathan Reinink + +Copyright (c) Hypervel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/inertia/README.md b/src/inertia/README.md new file mode 100644 index 000000000..37ea26f0e --- /dev/null +++ b/src/inertia/README.md @@ -0,0 +1,5 @@ +# Inertia.js Adapter for Hypervel + +The Inertia.js server-side adapter for Hypervel, providing middleware, response factories, SSR support, Blade directives, and testing utilities. + +Ported from the official [inertiajs/inertia-laravel](https://github.com/inertiajs/inertia-laravel) adapter with Swoole-specific optimisations: coroutine-safe per-request state isolation, worker-lifetime caching for immutable metadata, SSR timeouts with circuit breaker protection. diff --git a/src/inertia/composer.json b/src/inertia/composer.json new file mode 100644 index 000000000..f91cc7754 --- /dev/null +++ b/src/inertia/composer.json @@ -0,0 +1,65 @@ +{ + "name": "hypervel/inertia", + "type": "library", + "description": "The Inertia.js adapter for Hypervel.", + "license": "MIT", + "keywords": [ + "php", + "inertia", + "swoole", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Inertia\\": "src/" + }, + "files": [ + "src/helpers.php" + ] + }, + "require": { + "php": "^8.4", + "hypervel/console": "^0.4", + "hypervel/container": "^0.4", + "hypervel/context": "^0.4", + "hypervel/contracts": "^0.4", + "hypervel/foundation": "^0.4", + "hypervel/http": "^0.4", + "hypervel/macroable": "^0.4", + "hypervel/pagination": "^0.4", + "hypervel/routing": "^0.4", + "hypervel/session": "^0.4", + "hypervel/support": "^0.4", + "hypervel/testing": "^0.4", + "hypervel/view": "^0.4", + "symfony/console": "^8.0", + "symfony/process": "^8.0" + }, + "config": { + "sort-packages": true + }, + "extra": { + "hypervel": { + "providers": [ + "Hypervel\\Inertia\\InertiaServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "0.4-dev" + } + } +} diff --git a/src/inertia/config/inertia.php b/src/inertia/config/inertia.php new file mode 100644 index 000000000..14b02a6ea --- /dev/null +++ b/src/inertia/config/inertia.php @@ -0,0 +1,157 @@ + [ + 'enabled' => (bool) env('INERTIA_SSR_ENABLED', true), + + 'runtime' => env('INERTIA_SSR_RUNTIME', 'node'), + + 'ensure_runtime_exists' => (bool) env('INERTIA_SSR_ENSURE_RUNTIME_EXISTS', false), + + 'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'), + + 'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true), + + // 'bundle' => base_path('bootstrap/ssr/ssr.mjs'), + + /* + |-------------------------------------------------------------------------- + | SSR Timeouts + |-------------------------------------------------------------------------- + | + | Configure connection and read timeouts for SSR requests. These prevent + | coroutines from hanging indefinitely when the SSR server is unresponsive. + | + */ + + 'connect_timeout' => (int) env('INERTIA_SSR_CONNECT_TIMEOUT', 2), + + 'timeout' => (int) env('INERTIA_SSR_TIMEOUT', 5), + + /* + |-------------------------------------------------------------------------- + | SSR Backoff + |-------------------------------------------------------------------------- + | + | When SSR fails, the worker will skip SSR for this many seconds before + | retrying. This acts as a circuit breaker to prevent flooding a dead + | SSR server with requests from every coroutine. + | + */ + + 'backoff' => (float) env('INERTIA_SSR_BACKOFF', 5.0), + + /* + |-------------------------------------------------------------------------- + | SSR Error Handling + |-------------------------------------------------------------------------- + | + | When SSR rendering fails, Inertia gracefully falls back to client-side + | rendering. Set throw_on_error to true to throw an exception instead. + | This is useful for E2E testing where you want SSR errors to fail loudly. + | + | You can also listen for the Hypervel\Inertia\Ssr\SsrRenderFailed event + | to handle failures in your own way (e.g., logging, error tracking). + | + */ + + 'throw_on_error' => (bool) env('INERTIA_SSR_THROW_ON_ERROR', false), + ], + + /* + |-------------------------------------------------------------------------- + | Pages + |-------------------------------------------------------------------------- + | + | Set `ensure_pages_exist` to true if you want to enforce that Inertia page + | components exist on disk when rendering a page. This is useful for + | catching missing or misnamed components. + | + | The `paths` and `extensions` options define where to look for page + | components and which file extensions to consider. + | + */ + + 'pages' => [ + 'ensure_pages_exist' => false, + + 'paths' => [ + resource_path('js/pages'), + ], + + 'extensions' => [ + 'js', + 'jsx', + 'svelte', + 'ts', + 'tsx', + 'vue', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Testing + |-------------------------------------------------------------------------- + | + | When using `assertInertia`, the assertion attempts to locate the + | component as a file relative to the `pages.paths` AND with any of + | the `pages.extensions` specified above. + | + | You can disable this behavior by setting `ensure_pages_exist` + | to false. + | + */ + + 'testing' => [ + 'ensure_pages_exist' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Expose Shared Prop Keys + |-------------------------------------------------------------------------- + | + | When enabled, each page response includes a `sharedProps` metadata key + | listing the top-level prop keys that were registered via `Inertia::share`. + | The frontend can use this to carry shared props over during instant visits. + | + */ + + 'expose_shared_prop_keys' => true, + + /* + |-------------------------------------------------------------------------- + | History + |-------------------------------------------------------------------------- + | + | Enable `encrypt` to encrypt page data before it is stored in the + | browser's history state, preventing sensitive information from + | being accessible after logout. Can also be enabled per-request + | or via the `inertia.encrypt` middleware. + | + */ + + 'history' => [ + 'encrypt' => (bool) env('INERTIA_ENCRYPT_HISTORY', false), + ], +]; diff --git a/src/inertia/src/AlwaysProp.php b/src/inertia/src/AlwaysProp.php new file mode 100644 index 000000000..68b838f16 --- /dev/null +++ b/src/inertia/src/AlwaysProp.php @@ -0,0 +1,35 @@ +value = $value; + } + + /** + * Resolve the property value. + */ + public function __invoke(): mixed + { + return $this->resolveCallable($this->value); + } +} diff --git a/src/inertia/src/Commands/CheckSsr.php b/src/inertia/src/Commands/CheckSsr.php new file mode 100644 index 000000000..56b1a244c --- /dev/null +++ b/src/inertia/src/Commands/CheckSsr.php @@ -0,0 +1,42 @@ +error('The SSR gateway does not support health checks.'); + + return self::FAILURE; + } + + ($check = $gateway->isHealthy()) + ? $this->info('Inertia SSR server is running.') + : $this->error('Inertia SSR server is not running.'); + + return $check ? self::SUCCESS : self::FAILURE; + } +} diff --git a/src/inertia/src/Commands/CreateMiddleware.php b/src/inertia/src/Commands/CreateMiddleware.php new file mode 100644 index 000000000..b604c1953 --- /dev/null +++ b/src/inertia/src/Commands/CreateMiddleware.php @@ -0,0 +1,68 @@ +> + */ + protected function getArguments(): array + { + return [ + ['name', InputOption::VALUE_REQUIRED, 'Name of the Middleware that should be created', 'HandleInertiaRequests'], + ]; + } + + /** + * Get the console command options. + * + * @return array> + */ + protected function getOptions(): array + { + return [ + ['force', null, InputOption::VALUE_NONE, 'Create the class even if the Middleware already exists'], + ]; + } +} diff --git a/src/inertia/src/Commands/StartSsr.php b/src/inertia/src/Commands/StartSsr.php new file mode 100644 index 000000000..51a8f4d0d --- /dev/null +++ b/src/inertia/src/Commands/StartSsr.php @@ -0,0 +1,90 @@ +error('Inertia SSR is not enabled. Enable it via the `inertia.ssr.enabled` config option.'); + + return self::FAILURE; + } + + $bundle = (new BundleDetector)->detect(); + $configuredBundle = config('inertia.ssr.bundle'); + + if ($bundle === null) { + $this->error( + $configuredBundle + ? 'Inertia SSR bundle not found at the configured path: "' . $configuredBundle . '"' + : 'Inertia SSR bundle not found. Set the correct Inertia SSR bundle path in your `inertia.ssr.bundle` config.' + ); + + return self::FAILURE; + } + if ($configuredBundle && $bundle !== $configuredBundle) { + $this->warn('Inertia SSR bundle not found at the configured path: "' . $configuredBundle . '"'); + $this->warn('Using a default bundle instead: "' . $bundle . '"'); + } + + $runtime = $this->option('runtime') ?? config('inertia.ssr.runtime', 'node'); + + if (config('inertia.ssr.ensure_runtime_exists', false) && ! (new ExecutableFinder)->find($runtime)) { + $this->error('SSR runtime "' . $runtime . '" could not be found.'); + + return self::FAILURE; + } + + $this->callSilently('inertia:stop-ssr'); + + $process = app(Process::class, ['command' => [$runtime, $bundle]]); + $process->setTimeout(null); + $process->start(); + + if (extension_loaded('pcntl')) { + $stop = function () use ($process) { + $process->stop(); + }; + pcntl_async_signals(true); + pcntl_signal(SIGINT, $stop); + pcntl_signal(SIGQUIT, $stop); + pcntl_signal(SIGTERM, $stop); + } + + foreach ($process as $type => $data) { + if ($process::OUT === $type) { + $this->info(trim($data)); + } else { + $this->error(trim($data)); + report(new SsrException($data)); + } + } + + return self::SUCCESS; + } +} diff --git a/src/inertia/src/Commands/StopSsr.php b/src/inertia/src/Commands/StopSsr.php new file mode 100644 index 000000000..d486e36b2 --- /dev/null +++ b/src/inertia/src/Commands/StopSsr.php @@ -0,0 +1,50 @@ +getProductionUrl('/shutdown'); + + try { + Http::timeout(3)->get($url); + } catch (ConnectionException $e) { + // The shutdown endpoint closes the connection without a response, + // which triggers an "Empty reply from server" error. This is expected. + // Real connection failures produce "Connection refused" or similar. + if (! str_contains($e->getMessage(), 'Empty reply from server')) { + $this->error('Unable to connect to Inertia SSR server.'); + + return self::FAILURE; + } + } + + $this->info('Inertia SSR server stopped.'); + + return self::SUCCESS; + } +} diff --git a/src/inertia/src/ComponentNotFoundException.php b/src/inertia/src/ComponentNotFoundException.php new file mode 100644 index 000000000..2c4640c5c --- /dev/null +++ b/src/inertia/src/ComponentNotFoundException.php @@ -0,0 +1,11 @@ +route()->defaults['component'], + $request->route()->defaults['props'] + ); + } +} diff --git a/src/inertia/src/DeferProp.php b/src/inertia/src/DeferProp.php new file mode 100644 index 000000000..9dd53b447 --- /dev/null +++ b/src/inertia/src/DeferProp.php @@ -0,0 +1,41 @@ +callback = $callback; + $this->defer($group); + } + + /** + * Resolve the property value. + */ + public function __invoke(): mixed + { + return $this->resolveCallable($this->callback); + } +} diff --git a/src/inertia/src/Deferrable.php b/src/inertia/src/Deferrable.php new file mode 100644 index 000000000..4f11458f8 --- /dev/null +++ b/src/inertia/src/Deferrable.php @@ -0,0 +1,18 @@ +deferred = true; + $this->deferGroup = $group; + + return $this; + } + + /** + * Determine if this property should be deferred. + */ + public function shouldDefer(): bool + { + return $this->deferred; + } + + /** + * Get the defer group for this property. + */ + public function group(): string + { + return $this->deferGroup ?? 'default'; + } +} diff --git a/src/inertia/src/Directive.php b/src/inertia/src/Directive.php new file mode 100644 index 000000000..7d94cd764 --- /dev/null +++ b/src/inertia/src/Directive.php @@ -0,0 +1,48 @@ +body; + } else { + ?>
'; + + return implode(' ', array_map('trim', explode("\n", $template))); + } + + /** + * Compile the "@inertiaHead" Blade directive. This directive renders the + * head content for SSR responses, including meta tags, title, and other + * head elements from the server-side render. + */ + public static function compileHead(string $expression = ''): string + { + $template = 'head; + } + ?>'; + + return implode(' ', array_map('trim', explode("\n", $template))); + } +} diff --git a/src/inertia/src/EncryptHistoryMiddleware.php b/src/inertia/src/EncryptHistoryMiddleware.php new file mode 100644 index 000000000..3ec2a615e --- /dev/null +++ b/src/inertia/src/EncryptHistoryMiddleware.php @@ -0,0 +1,24 @@ + */ + protected array $props = []; + + protected bool $includeSharedData = false; + + protected ?string $rootView = null; + + /** @var null|class-string */ + protected ?string $middlewareClass = null; + + public function __construct( + public readonly Throwable $exception, + public readonly Request $request, + public readonly Response $response, + protected readonly Router $router, + protected readonly KernelContract $kernel, + ) { + } + + /** + * @param array $props + */ + public function render(string $component, array $props = []): static + { + $this->component = $component; + $this->props = $props; + + return $this; + } + + /** + * @param class-string $middlewareClass + */ + public function usingMiddleware(string $middlewareClass): static + { + $this->middlewareClass = $middlewareClass; + + return $this; + } + + public function withSharedData(): static + { + $this->includeSharedData = true; + + return $this; + } + + public function rootView(string $rootView): static + { + $this->rootView = $rootView; + + return $this; + } + + public function statusCode(): int + { + return $this->response->getStatusCode(); + } + + /** + * Create an HTTP response that represents the object. + */ + public function toResponse(Request $request): Response + { + if ($this->component === null) { + return $this->response; + } + + $middleware = $this->resolveMiddleware(); + + if ($middleware) { + Inertia::version(fn () => $middleware->version($this->request)); + Inertia::setRootView($this->rootView ?? $middleware->rootView($this->request)); + } elseif ($this->rootView) { + Inertia::setRootView($this->rootView); + } + + if ($this->includeSharedData && $middleware) { + Inertia::share($middleware->share($this->request)); + + foreach ($middleware->shareOnce($this->request) as $key => $value) { + if ($value instanceof OnceProp) { + Inertia::share($key, $value); + } else { + Inertia::shareOnce($key, $value); + } + } + } + + return Inertia::render($this->component, $this->props) + ->toResponse($this->request) + ->setStatusCode($this->response->getStatusCode()); + } + + protected function resolveMiddleware(): ?Middleware + { + if ($this->middlewareClass) { + return app($this->middlewareClass); + } + + $class = $this->resolveMiddlewareFromRoute() ?? $this->resolveMiddlewareFromKernel(); + + if ($class) { + return app($class); + } + + return null; + } + + /** + * @return null|class-string + */ + protected function resolveMiddlewareFromRoute(): ?string + { + $route = $this->request->route(); + + if (! $route) { + return null; + } + + foreach ($this->router->gatherRouteMiddleware($route) as $middleware) { + if (! is_string($middleware)) { + continue; + } + + $class = head(explode(':', $middleware)); + + if (is_a($class, Middleware::class, true)) { + return $class; + } + } + + return null; + } + + /** + * @return null|class-string + */ + protected function resolveMiddlewareFromKernel(): ?string + { + foreach ($this->kernel->getMiddlewareGroups() as $group) { + foreach ($group as $middleware) { + if (is_string($middleware) && is_a($middleware, Middleware::class, true)) { + return $middleware; + } + } + } + + return null; + } +} diff --git a/src/inertia/src/IgnoreFirstLoad.php b/src/inertia/src/IgnoreFirstLoad.php new file mode 100644 index 000000000..a56c834a4 --- /dev/null +++ b/src/inertia/src/IgnoreFirstLoad.php @@ -0,0 +1,9 @@ +|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Inertia\ProvidesInertiaProperties $key, mixed $value = null) + * @method static mixed getShared(string|null $key = null, mixed $default = null) + * @method static void flushShared() + * @method static void version(\Closure|string|null $version) + * @method static string getVersion() + * @method static void resolveUrlUsing(\Closure|null $urlResolver = null) + * @method static void transformComponentUsing(\Closure|null $componentTransformer = null) + * @method static void clearHistory() + * @method static void preserveFragment() + * @method static void encryptHistory(bool $encrypt = true) + * @method static void disableSsr(\Closure|bool $condition = true) + * @method static void withoutSsr(array|string $paths) + * @method static \Hypervel\Inertia\OptionalProp optional(callable $callback) + * @method static \Hypervel\Inertia\DeferProp defer(callable $callback, string $group = 'default') + * @method static \Hypervel\Inertia\MergeProp merge(mixed $value) + * @method static \Hypervel\Inertia\MergeProp deepMerge(mixed $value) + * @method static \Hypervel\Inertia\AlwaysProp always(mixed $value) + * @method static \Hypervel\Inertia\ScrollProp scroll(mixed $value, string $wrapper = 'data', \Hypervel\Inertia\ProvidesScrollMetadata|callable|null $metadata = null) + * @method static \Hypervel\Inertia\OnceProp once(callable $value) + * @method static \Hypervel\Inertia\OnceProp shareOnce(string $key, callable $callback) + * @method static \Hypervel\Inertia\Response render(\BackedEnum|\UnitEnum|string $component, array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Inertia\ProvidesInertiaProperties $props = []) + * @method static \Symfony\Component\HttpFoundation\Response location(string|\Symfony\Component\HttpFoundation\RedirectResponse $url) + * @method static void handleExceptionsUsing(callable $callback) + * @method static \Hypervel\Inertia\ResponseFactory flash(\BackedEnum|\UnitEnum|string|array $key, mixed $value = null) + * @method static \Symfony\Component\HttpFoundation\RedirectResponse back(int $status = 302, array $headers = [], mixed $fallback = false) + * @method static array getFlashed(\Hypervel\Http\Request|null $request = null) + * @method static array pullFlashed(\Hypervel\Http\Request|null $request = null) + * @method static void macro(string $name, object|callable $macro) + * @method static void mixin(object $mixin, bool $replace = true) + * @method static bool hasMacro(string $name) + * @method static void flushMacros() + * + * @see \Hypervel\Inertia\ResponseFactory + */ +class Inertia extends Facade +{ + protected static function getFacadeAccessor(): string + { + return ResponseFactory::class; + } +} diff --git a/src/inertia/src/InertiaServiceProvider.php b/src/inertia/src/InertiaServiceProvider.php new file mode 100644 index 000000000..86e202ef3 --- /dev/null +++ b/src/inertia/src/InertiaServiceProvider.php @@ -0,0 +1,183 @@ +app->singleton(Gateway::class, HttpGateway::class); + + $this->mergeConfigFrom( + __DIR__ . '/../config/inertia.php', + 'inertia' + ); + + $this->registerBladeComponents(); + $this->registerBladeDirectives(); + $this->registerRedirectMacro(); + $this->registerRequestMacro(); + $this->registerRouterMacro(); + $this->registerTestingMacros(); + $this->registerMiddleware(); + + $this->app->bind('inertia.view-finder', function ($app) { + return new FileViewFinder( + $app['files'], + $app['config']->get('inertia.pages.paths'), + $app['config']->get('inertia.pages.extensions') + ); + }); + } + + /** + * Boot the service provider. + */ + public function boot(): void + { + $this->registerConsoleCommands(); + $this->pushRedirectMiddleware(); + + $this->publishes([ + __DIR__ . '/../config/inertia.php' => config_path('inertia.php'), + ]); + } + + /** + * Register the global redirect middleware for Inertia requests. + */ + protected function pushRedirectMiddleware(): void + { + $this->callAfterResolving(HttpKernelContract::class, function ($kernel) { + if ($kernel instanceof Kernel) { + $kernel->pushMiddleware(Middleware\EnsureGetOnRedirect::class); + } + }); + } + + /** + * Register Blade components for rendering Inertia head and body content. + */ + protected function registerBladeComponents(): void + { + $this->callAfterResolving('blade.compiler', function () { + Blade::componentNamespace('Hypervel\Inertia\View\Components', 'inertia'); + }); + } + + /** + * Register @inertia and @inertiaHead directives for rendering the Inertia + * root element and SSR head content in Blade templates. + */ + protected function registerBladeDirectives(): void + { + $this->callAfterResolving('blade.compiler', function ($blade) { + $blade->directive('inertia', [Directive::class, 'compile']); + $blade->directive('inertiaHead', [Directive::class, 'compileHead']); + }); + } + + /** + * Register Artisan commands for managing Inertia middleware creation + * and server-side rendering operations when running in console mode. + */ + protected function registerConsoleCommands(): void + { + if (! $this->app->runningInConsole()) { + return; + } + + $this->commands([ + Commands\CreateMiddleware::class, + Commands\StartSsr::class, + Commands\StopSsr::class, + Commands\CheckSsr::class, + ]); + } + + /** + * Add a 'preserveFragment' method to redirect responses that signals + * the frontend to preserve the URL fragment across the redirect. + */ + protected function registerRedirectMacro(): void + { + RedirectResponse::macro('preserveFragment', function () { + inertia()->preserveFragment(); + + return $this; + }); + } + + /** + * Add an 'inertia' method to the Request class that returns true + * if the current request is an Inertia request. + */ + protected function registerRequestMacro(): void + { + Request::macro('inertia', function () { + return (bool) $this->header(Header::INERTIA); + }); + } + + /** + * Register the router macro. + */ + protected function registerRouterMacro(): void + { + /* + * @param array $props + */ + Router::macro('inertia', function ($uri, $component, $props = []) { + return $this->match(['GET', 'HEAD'], $uri, '\\' . Controller::class) + ->defaults('component', $component) + ->defaults('props', $props); + }); + } + + /** + * Register the testing macros. + * + * @throws LogicException + */ + protected function registerTestingMacros(): void + { + if (class_exists(TestResponse::class)) { + TestResponse::mixin(new TestResponseMacros); + + return; + } + + throw new LogicException('Could not detect TestResponse class.'); + } + + /** + * Register the middleware aliases. + */ + protected function registerMiddleware(): void + { + $this->app['router']->aliasMiddleware( + 'inertia.encrypt', + EncryptHistoryMiddleware::class + ); + } +} diff --git a/src/inertia/src/InertiaState.php b/src/inertia/src/InertiaState.php new file mode 100644 index 000000000..5efecbd57 --- /dev/null +++ b/src/inertia/src/InertiaState.php @@ -0,0 +1,112 @@ + + */ + public array $sharedProps = []; + + /** + * The asset version resolver or value. + */ + public Closure|string|null $version = null; + + /** + * Whether browser history encryption is enabled for this request. + */ + public ?bool $encryptHistory = null; + + /** + * The URL resolver callback for this request. + */ + public ?Closure $urlResolver = null; + + /** + * The component name transformer callback. + */ + public ?Closure $componentTransformer = null; + + // SSR per-request state (replaces upstream SsrState) + + /** + * The page data for the current request's SSR dispatch. + * + * @var array + */ + public array $page = []; + + /** + * The cached SSR response for the current request. + */ + public ?SsrResponse $ssrResponse = null; + + /** + * Whether the SSR gateway has been dispatched for this request. + */ + public bool $ssrDispatched = false; + + // SSR per-request flags (moved from HttpGateway) + + /** + * The condition that determines if SSR is disabled for this request. + */ + public Closure|bool|null $ssrDisabled = null; + + /** + * The paths excluded from SSR for this request. + * + * @var array + */ + public array $ssrExcludedPaths = []; + + /** + * Set the page data and dispatch SSR if not already dispatched. + * + * Used by Blade directives and view components to trigger SSR + * rendering. The result is cached so multiple calls (e.g. both + * @inertia and @inertiaHead) only dispatch once. + * + * @param array $page + */ + public static function dispatchSsr(array $page): ?SsrResponse + { + $state = CoroutineContext::getOrSet(self::CONTEXT_KEY, fn () => new self); + $state->page = $page; + + if (! $state->ssrDispatched) { + $state->ssrDispatched = true; + $state->ssrResponse = app(Gateway::class)->dispatch($state->page); + } + + return $state->ssrResponse; + } +} diff --git a/src/inertia/src/MergeProp.php b/src/inertia/src/MergeProp.php new file mode 100644 index 000000000..d45c63cab --- /dev/null +++ b/src/inertia/src/MergeProp.php @@ -0,0 +1,38 @@ +value = $value; + $this->merge = true; + } + + /** + * Resolve the property value. + */ + public function __invoke(): mixed + { + return $this->resolveCallable($this->value); + } +} diff --git a/src/inertia/src/Mergeable.php b/src/inertia/src/Mergeable.php new file mode 100644 index 000000000..791e1dc27 --- /dev/null +++ b/src/inertia/src/Mergeable.php @@ -0,0 +1,54 @@ + + */ + public function matchesOn(): array; + + /** + * Determine if the property should be appended at the root level. + */ + public function appendsAtRoot(): bool; + + /** + * Determine if the property should be prepended at the root level. + */ + public function prependsAtRoot(): bool; + + /** + * Get the paths to append when merging. + * + * @return array + */ + public function appendsAtPaths(): array; + + /** + * Get the paths to prepend when merging. + * + * @return array + */ + public function prependsAtPaths(): array; +} diff --git a/src/inertia/src/MergesProps.php b/src/inertia/src/MergesProps.php new file mode 100644 index 000000000..e4610b6ec --- /dev/null +++ b/src/inertia/src/MergesProps.php @@ -0,0 +1,192 @@ + + */ + protected array $matchOn = []; + + /** + * Indicates if the property values should be appended or prepended. + */ + protected bool $append = true; + + /** + * The paths to append. + * + * @var array + */ + protected array $appendsAtPaths = []; + + /** + * The paths to prepend. + * + * @var array + */ + protected array $prependsAtPaths = []; + + /** + * Mark the property for merging. + */ + public function merge(): static + { + $this->merge = true; + + return $this; + } + + /** + * Mark the property for deep merging. + */ + public function deepMerge(): static + { + $this->deepMerge = true; + + return $this->merge(); + } + + /** + * Set the properties to match on for merging. + * + * @param array|string $matchOn + */ + public function matchOn(string|array $matchOn): static + { + $this->matchOn = Arr::wrap($matchOn); + + return $this; + } + + /** + * Determine if the property should be merged. + */ + public function shouldMerge(): bool + { + return $this->merge; + } + + /** + * Determine if the property should be deep merged. + */ + public function shouldDeepMerge(): bool + { + return $this->deepMerge; + } + + /** + * Get the properties to match on for merging. + * + * @return array + */ + public function matchesOn(): array + { + return $this->matchOn; + } + + /** + * Determine if the property should be appended at the root level. + */ + public function appendsAtRoot(): bool + { + return $this->append && $this->mergesAtRoot(); + } + + /** + * Determine if the property should be prepended at the root level. + */ + public function prependsAtRoot(): bool + { + return ! $this->append && $this->mergesAtRoot(); + } + + /** + * Determine if the property merges at the root level. + */ + protected function mergesAtRoot(): bool + { + return count($this->appendsAtPaths) === 0 && count($this->prependsAtPaths) === 0; + } + + /** + * Specify that the value should be appended, optionally providing a key to append and a property to match on. + * + * @param array|bool|string $path + */ + public function append(bool|string|array $path = true, ?string $matchOn = null): static + { + match (true) { + is_bool($path) => $this->append = $path, + is_string($path) => $this->appendsAtPaths[] = $path, + is_array($path) => collect($path)->each( + fn ($value, $key) => is_numeric($key) ? $this->append($value) : $this->append($key, $value) + ), + }; + + if (is_string($path) && $matchOn) { + $this->matchOn([...$this->matchOn, "{$path}.{$matchOn}"]); + } + + return $this; + } + + /** + * Specify that the value should be prepended, optionally providing a key to prepend and a property to match on. + * + * @param array|bool|string $path + */ + public function prepend(bool|string|array $path = true, ?string $matchOn = null): static + { + match (true) { + is_bool($path) => $this->append = ! $path, + is_string($path) => $this->prependsAtPaths[] = $path, + is_array($path) => collect($path)->each( + fn ($value, $key) => is_numeric($key) ? $this->prepend($value) : $this->prepend($key, $value) + ), + }; + + if (is_string($path) && $matchOn) { + $this->matchOn([...$this->matchOn, "{$path}.{$matchOn}"]); + } + + return $this; + } + + /** + * Get the paths to append. + * + * @return array + */ + public function appendsAtPaths(): array + { + return $this->appendsAtPaths; + } + + /** + * Get the paths to prepend. + * + * @return array + */ + public function prependsAtPaths(): array + { + return $this->prependsAtPaths; + } +} diff --git a/src/inertia/src/Middleware.php b/src/inertia/src/Middleware.php new file mode 100644 index 000000000..5d6ac1693 --- /dev/null +++ b/src/inertia/src/Middleware.php @@ -0,0 +1,271 @@ + + */ + protected array $withoutSsr = []; + + /** + * The cached asset version for this worker. + */ + private static ?string $cachedVersion = null; + + /** + * Whether the version has been computed for this worker. + */ + private static bool $versionComputed = false; + + /** + * Determine the current asset version. + * + * The result is cached for the worker lifetime to avoid + * repeated filesystem I/O on every request. + */ + public function version(Request $request): ?string + { + if (! self::$versionComputed) { + self::$cachedVersion = $this->computeVersion(); + self::$versionComputed = true; + } + + return self::$cachedVersion; + } + + /** + * Compute the asset version from the manifest file. + */ + private function computeVersion(): ?string + { + if (config('app.asset_url')) { + return hash('xxh128', (string) config('app.asset_url')); + } + + if (file_exists($manifest = public_path('build/manifest.json'))) { + return hash_file('xxh128', $manifest) ?: null; + } + + if (file_exists($manifest = public_path('mix-manifest.json'))) { + return hash_file('xxh128', $manifest) ?: null; + } + + return null; + } + + /** + * Define the props that are shared by default. + * + * @return array + */ + public function share(Request $request): array + { + return [ + 'errors' => Inertia::always($this->resolveValidationErrors($request)), + ]; + } + + /** + * Define the props that are shared once and remembered across navigations. + * + * @return array + */ + public function shareOnce(Request $request): array + { + return []; + } + + /** + * Set the root template that is loaded on the first page visit. + */ + public function rootView(Request $request): string + { + return $this->rootView; + } + + /** + * Define a callback that returns the relative URL. + */ + public function urlResolver(): ?Closure + { + return null; + } + + /** + * Handle the incoming request. + */ + public function handle(Request $request, Closure $next): Response + { + Inertia::version(function () use ($request) { + return $this->version($request); + }); + + Inertia::share($this->share($request)); + + foreach ($this->shareOnce($request) as $key => $value) { + if ($value instanceof OnceProp) { + Inertia::share($key, $value); + } else { + Inertia::shareOnce($key, $value); + } + } + + Inertia::setRootView($this->rootView($request)); + + if ($urlResolver = $this->urlResolver()) { + Inertia::resolveUrlUsing($urlResolver); + } + + $ssrGateway = app(Gateway::class); + + if (! empty($this->withoutSsr) && $ssrGateway instanceof ExcludesSsrPaths) { + $ssrGateway->except($this->withoutSsr); + } + + $response = $next($request); + $response->headers->set('Vary', Header::INERTIA); + + if ($isRedirect = $response->isRedirect()) { + $this->reflash($request); + } + + if (! $request->header(Header::INERTIA)) { + return $response; + } + + if ($request->method() === 'GET' && $request->header(Header::VERSION, '') !== Inertia::getVersion()) { + $response = $this->onVersionChange($request, $response); + } + + if ($response->isOk() && empty($response->getContent())) { + $response = $this->onEmptyResponse($request, $response); + } + + if ($response->getStatusCode() === 302 && in_array($request->method(), ['PUT', 'PATCH', 'DELETE'])) { + $response->setStatusCode(303); + } + + if ($isRedirect && $this->redirectHasFragment($response) && ! $request->prefetch()) { + $response = $this->onRedirectWithFragment($request, $response); + } + + return $response; + } + + /** + * Determine if the redirect response contains a URL fragment. + */ + protected function redirectHasFragment(Response $response): bool + { + return str_contains($response->headers->get('Location', ''), '#'); + } + + /** + * Reflash the session data for the next request. + */ + protected function reflash(Request $request): void + { + if ($flashed = Inertia::getFlashed($request)) { + $request->session()->flash(SessionKey::FLASH_DATA, $flashed); + } + } + + /** + * Handle empty responses. + */ + public function onEmptyResponse(Request $request, Response $response): Response + { + return Redirect::back(); + } + + /** + * Handle redirects with URL fragments. + */ + public function onRedirectWithFragment(Request $request, Response $response): Response + { + return response('', 409, [ + Header::REDIRECT => $response->headers->get('Location'), + ]); + } + + /** + * Handle version changes. + */ + public function onVersionChange(Request $request, Response $response): Response + { + if ($request->hasSession()) { + /** @var Store $session */ + $session = $request->session(); + $session->reflash(); + } + + return Inertia::location($request->fullUrl()); + } + + /** + * Resolve validation errors for client-side use. + */ + public function resolveValidationErrors(Request $request): object + { + if (! $request->hasSession() || ! $request->session()->has('errors')) { + return (object) []; + } + + /** @var array $bags */ + $bags = $request->session()->get('errors')->getBags(); + + return (object) collect($bags)->map(function ($bag) { + return (object) collect($bag->messages())->map(function ($errors) { + return $this->withAllErrors ? $errors : $errors[0]; + })->toArray(); + })->pipe(function ($bags) use ($request) { + if ($bags->has('default') && $request->header(Header::ERROR_BAG)) { + return [$request->header(Header::ERROR_BAG) => $bags->get('default')]; + } + + if ($bags->has('default')) { + return $bags->get('default'); + } + + return $bags->toArray(); + }); + } + + /** + * Reset the cached version state. + */ + public static function flushState(): void + { + self::$cachedVersion = null; + self::$versionComputed = false; + } +} diff --git a/src/inertia/src/Middleware/EncryptHistory.php b/src/inertia/src/Middleware/EncryptHistory.php new file mode 100644 index 000000000..fd67b4da3 --- /dev/null +++ b/src/inertia/src/Middleware/EncryptHistory.php @@ -0,0 +1,11 @@ +getStatusCode() === 302 + && $request->header(Header::INERTIA) + && in_array($request->method(), ['PUT', 'PATCH', 'DELETE']) + ) { + $response->setStatusCode(303); + } + + return $response; + } +} diff --git a/src/inertia/src/OnceProp.php b/src/inertia/src/OnceProp.php new file mode 100644 index 000000000..82a0b259a --- /dev/null +++ b/src/inertia/src/OnceProp.php @@ -0,0 +1,35 @@ +callback = $callback; + $this->once = true; + } + + /** + * Resolve the property value. + */ + public function __invoke(): mixed + { + return $this->resolveCallable($this->callback); + } +} diff --git a/src/inertia/src/Onceable.php b/src/inertia/src/Onceable.php new file mode 100644 index 000000000..a040d3828 --- /dev/null +++ b/src/inertia/src/Onceable.php @@ -0,0 +1,48 @@ +callback = $callback; + } + + /** + * Resolve the property value. + */ + public function __invoke(): mixed + { + return $this->resolveCallable($this->callback); + } +} diff --git a/src/inertia/src/PropertyContext.php b/src/inertia/src/PropertyContext.php new file mode 100644 index 000000000..1a4344321 --- /dev/null +++ b/src/inertia/src/PropertyContext.php @@ -0,0 +1,24 @@ + $props + */ + public function __construct( + public readonly string $key, + public readonly array $props, + public readonly Request $request, + ) { + } +} diff --git a/src/inertia/src/PropsResolver.php b/src/inertia/src/PropsResolver.php new file mode 100644 index 000000000..15fced8f9 --- /dev/null +++ b/src/inertia/src/PropsResolver.php @@ -0,0 +1,675 @@ + + */ + protected ?array $only; + + /** + * The props to exclude from the partial response. + * + * @var null|array + */ + protected ?array $except; + + /** + * The props that should have their merge state reset. + * + * @var array + */ + protected array $resetProps; + + /** + * The once-props that the client has already loaded. + * + * @var array + */ + protected array $loadedOnceProps; + + /** + * The deferred props grouped by their defer group. + * + * @var array> + */ + protected array $deferredProps = []; + + /** + * The props that should be appended to existing client-side data. + * + * @var array + */ + protected array $mergeProps = []; + + /** + * The props that should be prepended to existing client-side data. + * + * @var array + */ + protected array $prependProps = []; + + /** + * The props that should be deep merged with existing client-side data. + * + * @var array + */ + protected array $deepMergeProps = []; + + /** + * The key matching strategies for mergeable props. + * + * @var array + */ + protected array $matchPropsOn = []; + + /** + * The scroll pagination metadata for each scroll prop. + * + * @var array> + */ + protected array $scrollProps = []; + + /** + * The once-prop metadata for each once prop. + * + * @var array> + */ + protected array $onceProps = []; + + /** + * The top-level keys of shared props. + * + * @var array + */ + protected array $sharedPropKeys = []; + + /** + * Create a new props resolver instance. + */ + public function __construct(Request $request, string $component) + { + $this->request = $request; + $this->component = $component; + + $this->isPartial = $request->header(Header::PARTIAL_COMPONENT) === $component; + $this->isInertia = (bool) $request->header(Header::INERTIA); + $this->only = $this->parseHeader(Header::PARTIAL_ONLY); + $this->except = $this->parseHeader(Header::PARTIAL_EXCEPT); + $this->resetProps = $this->parseHeader(Header::RESET) ?? []; + $this->loadedOnceProps = $this->parseHeader(Header::EXCEPT_ONCE_PROPS) ?? []; + } + + /** + * Resolve the given shared and page props, collecting their metadata. + * + * @param array $shared + * @param array $props + * @return array{array, array} + */ + public function resolve(array $shared, array $props): array + { + $props = array_merge($this->resolveSharedProps($shared), $props); + + return [ + $this->resolveProps($this->unpackDotProps($props)), + $this->buildMetadata(), + ]; + } + + /** + * Resolve shared property providers and collect shared prop keys. + * + * @param array $shared + * @return array + */ + protected function resolveSharedProps(array $shared): array + { + $resolved = $this->resolvePropertyProviders($shared); + + if (! config('inertia.expose_shared_prop_keys', true)) { + return $resolved; + } + + foreach (array_keys($resolved) as $key) { + $this->sharedPropKeys[] = str_contains((string) $key, '.') + ? strstr((string) $key, '.', true) + : (string) $key; + } + + $this->sharedPropKeys = array_values(array_unique($this->sharedPropKeys)); + + return $resolved; + } + + /** + * Resolve ProvidesInertiaProperties instances into keyed props. + * + * @param array $props + * @return array + */ + protected function resolvePropertyProviders(array $props): array + { + $context = null; + $result = []; + + foreach ($props as $key => $value) { + if (is_numeric($key) && $value instanceof ProvidesInertiaProperties) { + $context ??= new RenderContext($this->component, $this->request); + + /** @var array $provided */ + $provided = collect($value->toInertiaProperties($context))->all(); + $result = array_merge($result, $provided); + } else { + $result[$key] = $value; + } + } + + return $result; + } + + /** + * Build the non-empty metadata arrays for the page response. + * + * @return array + */ + protected function buildMetadata(): array + { + return array_filter([ + 'sharedProps' => $this->sharedPropKeys, + 'mergeProps' => $this->mergeProps, + 'prependProps' => $this->prependProps, + 'deepMergeProps' => $this->deepMergeProps, + 'matchPropsOn' => $this->matchPropsOn, + 'deferredProps' => $this->deferredProps, + 'scrollProps' => $this->scrollProps, + 'onceProps' => $this->onceProps, + ], fn ($value) => count($value) > 0); + } + + /** + * Recursively resolve the props tree, collecting metadata along the way. + * + * @param array $props + * @return array + */ + protected function resolveProps(array $props, string $prefix = '', bool $parentWasResolved = false): array + { + $props = $this->resolvePropertyProviders($props); + $result = []; + + foreach ($props as $key => $value) { + $path = $prefix === '' ? $key : "{$prefix}.{$key}"; + $prop = $value; + + // On partial requests, we only include props that match the paths + // specified in the request headers. AlwaysProp instances and the + // children of already-resolved values bypass this filter. + if (! $this->shouldIncludeInPartialResponse($prop, $path, $parentWasResolved)) { + continue; + } + + // On initial page loads, certain prop types (e.g. DeferProp, + // OptionalProp) are excluded before resolution to avoid + // executing their closures unnecessarily. + if (! $this->isPartial && $this->excludeFromInitialResponse($prop, $path)) { + continue; + } + + $value = $this->resolveValue($prop, $path, $props); + + // A closure may return a prop type instead of a plain value. When + // this happens, we unwrap it one more level so the prop type can + // participate in filtering and metadata collection below. + if ($value !== $prop && $this->isPropType($value)) { + $prop = $value; + + // Check again after unwrapping: the resolved prop type may + // itself need to be excluded from the initial response. + if (! $this->isPartial && $this->excludeFromInitialResponse($prop, $path)) { + continue; + } + + $value = $this->resolveValue($prop, $path, $props); + } + + $this->collectMetadata($prop, $path); + + // When the resolved value is an array, we recurse into it. If the + // original prop was not already an array (e.g. a closure that + // returned one), its children bypass partial filtering. + $result[$key] = is_array($value) + ? $this->resolveProps($value, $path, $parentWasResolved || ! is_array($prop)) + : $value; + } + + return $result; + } + + /** + * Determine if a prop should be included in a partial response. AlwaysProp + * and children of already-resolved values bypass partial filtering. + */ + protected function shouldIncludeInPartialResponse(mixed $prop, string $path, bool $parentWasResolved): bool + { + if (! $this->isPartial || $prop instanceof AlwaysProp || $parentWasResolved) { + return true; + } + + return $this->pathMatchesPartialRequest($path); + } + + /** + * Determine if the given path matches the current partial request using + * bidirectional prefix matching on the only/except headers. + */ + protected function pathMatchesPartialRequest(string $path): bool + { + if ($this->only !== null && ! $this->matchesOnly($path) && ! $this->leadsToOnly($path)) { + return false; + } + + if ($this->except !== null && $this->matchesExcept($path)) { + return false; + } + + return true; + } + + /** + * Determine if a prop should be excluded from the initial page response. + * Each exclusion type collects its metadata before the prop is removed. + */ + protected function excludeFromInitialResponse(mixed $prop, string $path): bool + { + // OptionalProp and DeferProp implement IgnoreFirstLoad and are never + // sent on the initial page load. They still contribute deferred, + // merge, and once metadata for the client to act on. + if ($prop instanceof IgnoreFirstLoad) { + return $this->excludeIgnoredProp($prop, $path); + } + + // ScrollProp and other Deferrable types may be configured to defer + // their initial load. They contribute deferred-group and merge + // metadata so the client knows to request them separately. + if ($prop instanceof Deferrable && $prop->shouldDefer()) { + return $this->excludeDeferredProp($prop, $path); + } + + // Once-props that the client has already loaded are excluded on + // subsequent Inertia visits to avoid sending duplicate data. + // The client tracks loaded once-props via the except-once header. + if ($this->isInertia && $this->wasAlreadyLoadedByClient($prop, $path)) { + return $this->excludeAlreadyLoadedProp($prop, $path); + } + + return false; + } + + /** + * Exclude an IgnoreFirstLoad prop from the initial response while + * collecting its deferred, merge, and once metadata. + */ + protected function excludeIgnoredProp(mixed $prop, string $path): bool + { + if ($prop instanceof Deferrable && $prop->shouldDefer() + && ! $this->wasAlreadyLoadedByClient($prop, $path)) { + $this->collectDeferredPropMetadata($path, $prop); + } + + if ($prop instanceof Mergeable && $prop->shouldMerge()) { + $this->collectMergeableMetadata($path, $prop); + } + + if ($prop instanceof Onceable && $prop->shouldResolveOnce()) { + $this->collectOnceMetadata($path, $prop); + } + + return true; + } + + /** + * Exclude a deferred prop from the initial response while + * collecting its deferred-group and merge metadata. + */ + protected function excludeDeferredProp(Deferrable $prop, string $path): bool + { + $this->collectDeferredPropMetadata($path, $prop); + + if ($prop instanceof Mergeable && $prop->shouldMerge()) { + $this->collectMergeableMetadata($path, $prop); + } + + return true; + } + + /** + * Exclude a once-prop that the client has already loaded while + * preserving its once metadata for the client. + */ + protected function excludeAlreadyLoadedProp(mixed $prop, string $path): bool + { + $this->collectOnceMetadata($path, $prop); + + return true; + } + + /** + * Determine if a once-prop has already been loaded by the client. + */ + protected function wasAlreadyLoadedByClient(mixed $prop, string $path): bool + { + return $prop instanceof Onceable + && $prop->shouldResolveOnce() + && ! $prop->shouldBeRefreshed() + && in_array($prop->getKey() ?? $path, $this->loadedOnceProps); + } + + /** + * Resolve a single prop value through the resolution pipeline. + * + * @param array $siblings + */ + protected function resolveValue(mixed $value, string $path, array $siblings): mixed + { + if ($value instanceof ScrollProp) { + $value->configureMergeIntent($this->request); + } + + $value = $this->resolveCallable($value); + + if ($value instanceof ProvidesInertiaProperty) { + $value = $value->toInertiaProperty(new PropertyContext($path, $siblings, $this->request)); + } + + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } + + if ($value instanceof PromiseInterface) { + $value = $value->wait(); + } + + if ($value instanceof Responsable) { + $response = $value->toResponse($this->request); + + if (method_exists($response, 'getData')) { + $value = $response->getData(true); + } + } + + return $value; + } + + /** + * Determine if the value is a prop type that requires + * further filtering or metadata collection. + */ + protected function isPropType(mixed $value): bool + { + return $value instanceof AlwaysProp + || $value instanceof Deferrable + || $value instanceof IgnoreFirstLoad + || $value instanceof Mergeable + || $value instanceof Onceable; + } + + /** + * Collect metadata for a prop that will be included in the response. + */ + protected function collectMetadata(mixed $prop, string $path): void + { + if ($prop instanceof Mergeable && $prop->shouldMerge()) { + $this->collectMergeableMetadata($path, $prop); + } + + if ($prop instanceof ScrollProp) { + $this->collectScrollMetadata($path, $prop); + } + + if ($prop instanceof Onceable && $prop->shouldResolveOnce()) { + $this->collectOnceMetadata($path, $prop); + } + } + + /** + * Collect the deferred prop and its group. + */ + protected function collectDeferredPropMetadata(string $path, Deferrable $prop): void + { + $this->deferredProps[$prop->group()][] = $path; + } + + /** + * Collect the merge strategy for a mergeable prop. + */ + protected function collectMergeableMetadata(string $path, Mergeable $prop): void + { + if (in_array($path, $this->resetProps)) { + return; + } + + if ($this->isPartial && ! $this->isIncludedInPartialMetadata($path)) { + return; + } + + if ($prop->shouldDeepMerge()) { + $this->deepMergeProps[] = $path; + } elseif ($prop->appendsAtRoot()) { + $this->mergeProps[] = $path; + } elseif ($prop->prependsAtRoot()) { + $this->prependProps[] = $path; + } else { + foreach ($prop->appendsAtPaths() as $appendPath) { + $this->mergeProps[] = "{$path}.{$appendPath}"; + } + foreach ($prop->prependsAtPaths() as $prependPath) { + $this->prependProps[] = "{$path}.{$prependPath}"; + } + } + + foreach ($prop->matchesOn() as $strategy) { + $this->matchPropsOn[] = "{$path}.{$strategy}"; + } + } + + /** + * Collect scroll pagination metadata. + * + * @param ScrollProp $prop + */ + protected function collectScrollMetadata(string $path, ScrollProp $prop): void + { + $this->scrollProps[$path] = [ + ...$prop->metadata(), + 'reset' => in_array($path, $this->resetProps), + ]; + } + + /** + * Collect once-prop metadata. + */ + protected function collectOnceMetadata(string $path, mixed $prop): void + { + if (! $prop instanceof Onceable || ! $prop->shouldResolveOnce()) { + return; + } + + if ($this->isPartial && ! $this->isIncludedInPartialMetadata($path)) { + return; + } + + $this->onceProps[$prop->getKey() ?? $path] = [ + 'prop' => $path, + 'expiresAt' => $prop->expiresAt(), + ]; + } + + /** + * Determine if the path should contribute metadata during a partial request. + */ + protected function isIncludedInPartialMetadata(string $path): bool + { + if ($this->only !== null && ! $this->matchesOnly($path)) { + return false; + } + + if ($this->except !== null && $this->matchesExcept($path)) { + return false; + } + + return true; + } + + /** + * Determine if the path matches or is a descendant of an "only" filter path. + */ + protected function matchesOnly(string $path): bool + { + foreach ($this->only as $onlyPath) { + if ($path === $onlyPath || str_starts_with($path, "{$onlyPath}.")) { + return true; + } + } + + return false; + } + + /** + * Determine if the path is an ancestor of an "only" filter path. + */ + protected function leadsToOnly(string $path): bool + { + foreach ($this->only as $onlyPath) { + if (str_starts_with($onlyPath, "{$path}.")) { + return true; + } + } + + return false; + } + + /** + * Determine if the path matches or is a descendant of an "except" filter path. + */ + protected function matchesExcept(string $path): bool + { + foreach ($this->except as $exceptPath) { + if ($path === $exceptPath || str_starts_with($path, "{$exceptPath}.")) { + return true; + } + } + + return false; + } + + /** + * Unpack top-level dot-notation keys into nested arrays. + * + * @param array $props + * @return array + */ + protected function unpackDotProps(array $props): array + { + foreach ($props as $key => $value) { + if (! is_string($key) || ! str_contains($key, '.')) { + continue; + } + + if ($value instanceof Closure) { + $value = App::call($value); + } + + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } + + $this->ensurePathIsTraversable($props, $key); + Arr::set($props, $key, $value); + unset($props[$key]); + } + + return $props; + } + + /** + * Resolve closures and Arrayable values along the intermediate segments + * of a dot-notation path so that Arr::set can nest into them. + * + * @param array $props + */ + protected function ensurePathIsTraversable(array &$props, string $dotKey): void + { + $segments = explode('.', $dotKey); + array_pop($segments); + + $current = &$props; + + foreach ($segments as $segment) { + if (! isset($current[$segment])) { + return; + } + + if ($current[$segment] instanceof Closure) { + $current[$segment] = App::call($current[$segment]); + } + + if ($current[$segment] instanceof Arrayable) { + $current[$segment] = $current[$segment]->toArray(); + } + + if (! is_array($current[$segment])) { + return; + } + + $current = &$current[$segment]; + } + } + + /** + * Parse a comma-separated header value into an array. + * + * @return null|array + */ + protected function parseHeader(string $key): ?array + { + return array_filter(explode(',', $this->request->header($key, ''))) ?: null; + } +} diff --git a/src/inertia/src/ProvidesInertiaProperties.php b/src/inertia/src/ProvidesInertiaProperties.php new file mode 100644 index 000000000..425c7bf37 --- /dev/null +++ b/src/inertia/src/ProvidesInertiaProperties.php @@ -0,0 +1,17 @@ + + */ + public function toInertiaProperties(RenderContext $context): iterable; +} diff --git a/src/inertia/src/ProvidesInertiaProperty.php b/src/inertia/src/ProvidesInertiaProperty.php new file mode 100644 index 000000000..18350ff5a --- /dev/null +++ b/src/inertia/src/ProvidesInertiaProperty.php @@ -0,0 +1,15 @@ +useAsCallable($value) ? App::call($value) : $value; + } + + /** + * Determine if the given value is callable, but not a string. + */ + protected function useAsCallable(mixed $value): bool + { + return is_object($value) && is_callable($value); + } +} diff --git a/src/inertia/src/ResolvesOnce.php b/src/inertia/src/ResolvesOnce.php new file mode 100644 index 000000000..46ba37da0 --- /dev/null +++ b/src/inertia/src/ResolvesOnce.php @@ -0,0 +1,124 @@ +once = $value; + + if ($as !== null) { + $this->as($as); + } + + if ($until !== null) { + $this->until($until); + } + + return $this; + } + + /** + * Determine if the prop should be resolved only once. + */ + public function shouldResolveOnce(): bool + { + return $this->once; + } + + /** + * Determine if the prop should be forcefully refreshed. + */ + public function shouldBeRefreshed(): bool + { + return $this->refresh; + } + + /** + * Get the custom key for resolving the once prop. + */ + public function getKey(): ?string + { + return $this->key; + } + + /** + * Set a custom key for resolving the once prop. + */ + public function as(BackedEnum|UnitEnum|string $key): static + { + $this->key = match (true) { + $key instanceof BackedEnum => $key->value, + $key instanceof UnitEnum => $key->name, + default => $key, + }; + + return $this; + } + + /** + * Mark the property to be forcefully sent to the client. + */ + public function fresh(bool $value = true): static + { + $this->refresh = $value; + + return $this; + } + + /** + * Set the expiration for the once prop. + */ + public function until(DateTimeInterface|DateInterval|int $delay): static + { + $this->ttl = $this->secondsUntil($delay); + + return $this; + } + + /** + * Get the expiration timestamp in milliseconds for the once prop. + */ + public function expiresAt(): ?int + { + if ($this->ttl === null) { + return null; + } + + return $this->availableAt($this->ttl) * 1000; + } +} diff --git a/src/inertia/src/Response.php b/src/inertia/src/Response.php new file mode 100644 index 000000000..46e166575 --- /dev/null +++ b/src/inertia/src/Response.php @@ -0,0 +1,272 @@ + + */ + protected array $props; + + /** + * The name of the root view. + */ + protected string $rootView; + + /** + * The asset version. + */ + protected string $version; + + /** + * Indicates if the browser history should be cleared. + */ + protected bool $clearHistory; + + /** + * Indicates if the URL fragment should be preserved across redirects. + */ + protected bool $preserveFragment; + + /** + * Indicates if the browser history should be encrypted. + */ + protected bool $encryptHistory; + + /** + * The view data. + * + * @var array + */ + protected array $viewData = []; + + /** + * The URL resolver callback. + */ + protected ?Closure $urlResolver = null; + + /** + * The shared properties (before merge with page props). + * + * @var array + */ + protected array $sharedProps = []; + + /** + * Create a new Inertia response instance. + * + * @param array $sharedProps + * @param array $props + */ + public function __construct( + string $component, + array $sharedProps, + array $props, + string $rootView = 'app', + string $version = '', + bool $encryptHistory = false, + ?Closure $urlResolver = null, + ) { + $this->component = $component; + $this->sharedProps = $sharedProps; + $this->props = $props; + $this->rootView = $rootView; + $this->version = $version; + $this->clearHistory = session()->pull(SessionKey::CLEAR_HISTORY, false); + $this->preserveFragment = session()->pull(SessionKey::PRESERVE_FRAGMENT, false); + $this->encryptHistory = $encryptHistory; + $this->urlResolver = $urlResolver; + } + + /** + * Add additional properties to the page. + * + * @param array|ProvidesInertiaProperties|string $key + * @param mixed $value + * @return $this + */ + public function with($key, $value = null): self + { + if ($key instanceof ProvidesInertiaProperties) { + $this->props[] = $key; + } elseif (is_array($key)) { + $this->props = array_merge($this->props, $key); + } else { + $this->props[$key] = $value; + } + + return $this; + } + + /** + * Add additional data to the view. + * + * @param array|string $key + * @param mixed $value + * @return $this + */ + public function withViewData($key, $value = null): self + { + if (is_array($key)) { + $this->viewData = array_merge($this->viewData, $key); + } else { + $this->viewData[$key] = $value; + } + + return $this; + } + + /** + * Set the root view. + * + * @return $this + */ + public function rootView(string $rootView): self + { + $this->rootView = $rootView; + + return $this; + } + + /** + * Add flash data to the response. + * + * @param array|BackedEnum|string|UnitEnum $key + * @return $this + */ + public function flash(BackedEnum|UnitEnum|string|array $key, mixed $value = null): self + { + Inertia::flash($key, $value); + + return $this; + } + + /** + * Create an HTTP response that represents the object. + */ + public function toResponse(Request $request): SymfonyResponse + { + $resolver = new PropsResolver($request, $this->component); + [$resolvedProps, $resolvedMetadata] = $resolver->resolve($this->sharedProps, $this->props); + + $page = array_merge( + [ + 'component' => $this->component, + 'props' => $resolvedProps, + 'url' => $this->getUrl($request), + 'version' => $this->version, + ], + $resolvedMetadata, + $this->resolveClearHistory($request), + $this->resolveEncryptHistory($request), + $this->resolveFlashData($request), + $this->resolvePreserveFragment($request), + ); + + if ($request->header(Header::INERTIA)) { + return new JsonResponse($page, 200, [Header::INERTIA => 'true']); + } + + CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState)->page = $page; + + return ResponseFactory::view($this->rootView, $this->viewData + ['page' => $page]); + } + + /** + * Resolve the clear history flag. + * + * @return array + */ + protected function resolveClearHistory(Request $request): array + { + return $this->clearHistory ? ['clearHistory' => true] : []; + } + + /** + * Resolve the encrypt history flag. + * + * @return array + */ + protected function resolveEncryptHistory(Request $request): array + { + return $this->encryptHistory ? ['encryptHistory' => true] : []; + } + + /** + * Resolve flash data from the session. + * + * @return array + */ + protected function resolveFlashData(Request $request): array + { + $flash = Inertia::pullFlashed($request); + + return $flash ? ['flash' => $flash] : []; + } + + /** + * Resolve the preserve fragment flag from the session. + * + * @return array + */ + protected function resolvePreserveFragment(Request $request): array + { + return $this->preserveFragment ? ['preserveFragment' => true] : []; + } + + /** + * Get the URL from the request while preserving the trailing slash. + */ + protected function getUrl(Request $request): string + { + $urlResolver = $this->urlResolver ?? function (Request $request) { + $url = Str::start(Str::after($request->fullUrl(), $request->getSchemeAndHttpHost()), '/'); + + $rawUri = Str::before($request->getRequestUri(), '?'); + + return Str::endsWith($rawUri, '/') ? $this->finishUrlWithTrailingSlash($url) : $url; + }; + + return App::call($urlResolver, ['request' => $request]); + } + + /** + * Ensure the URL has a trailing slash before the query string. + */ + protected function finishUrlWithTrailingSlash(string $url): string + { + // Make sure the relative URL ends with a trailing slash and re-append the query string if it exists. + $urlWithoutQueryWithTrailingSlash = Str::finish(Str::before($url, '?'), '/'); + + return str_contains($url, '?') + ? $urlWithoutQueryWithTrailingSlash . '?' . Str::after($url, '?') + : $urlWithoutQueryWithTrailingSlash; + } +} diff --git a/src/inertia/src/ResponseFactory.php b/src/inertia/src/ResponseFactory.php new file mode 100644 index 000000000..0fe415b4c --- /dev/null +++ b/src/inertia/src/ResponseFactory.php @@ -0,0 +1,456 @@ + new InertiaState); + } + + /** + * Set the root view template for Inertia responses. This template + * serves as the HTML wrapper that contains the Inertia root element + * where the frontend application will be mounted. + */ + public function setRootView(string $name): void + { + $this->state()->rootView = $name; + } + + /** + * Share data across all Inertia responses. This data is automatically + * included with every response, making it ideal for user authentication + * state, flash messages, etc. + * + * @param array|Arrayable|ProvidesInertiaProperties|string $key + */ + public function share(mixed $key, mixed $value = null): void + { + $state = $this->state(); + + if (is_array($key)) { + $state->sharedProps = array_merge($state->sharedProps, $key); + } elseif ($key instanceof Arrayable) { + $state->sharedProps = array_merge($state->sharedProps, $key->toArray()); + } elseif ($key instanceof ProvidesInertiaProperties) { + $state->sharedProps = array_merge($state->sharedProps, [$key]); + } else { + Arr::set($state->sharedProps, $key, $value); + } + } + + /** + * Get the shared data for a given key. Returns all shared data if + * no key is provided, or the value for a specific key with an + * optional default fallback. + */ + public function getShared(?string $key = null, mixed $default = null): mixed + { + $sharedProps = $this->state()->sharedProps; + + if ($key) { + return Arr::get($sharedProps, $key, $default); + } + + return $sharedProps; + } + + /** + * Flush all shared data. + */ + public function flushShared(): void + { + $this->state()->sharedProps = []; + } + + /** + * Set the asset version. + */ + public function version(Closure|string|null $version): void + { + $this->state()->version = $version; + } + + /** + * Get the asset version. + */ + public function getVersion(): string + { + $version = $this->state()->version; + + $version = $version instanceof Closure + ? App::call($version) + : $version; + + return (string) $version; + } + + /** + * Set the URL resolver. + */ + public function resolveUrlUsing(?Closure $urlResolver = null): void + { + $this->state()->urlResolver = $urlResolver; + } + + /** + * Set the component transformer. + */ + public function transformComponentUsing(?Closure $componentTransformer = null): void + { + $this->state()->componentTransformer = $componentTransformer; + } + + /** + * Clear the browser history on the next visit. + */ + public function clearHistory(): void + { + session([SessionKey::CLEAR_HISTORY => true]); + } + + /** + * Preserve the URL fragment across the next redirect. + */ + public function preserveFragment(): void + { + session([SessionKey::PRESERVE_FRAGMENT => true]); + } + + /** + * Encrypt the browser history. + */ + public function encryptHistory(bool $encrypt = true): void + { + $this->state()->encryptHistory = $encrypt; + } + + /** + * Disable server-side rendering, optionally based on a condition. + */ + public function disableSsr(Closure|bool $condition = true): void + { + $gateway = app(Gateway::class); + + if (! $gateway instanceof DisablesSsr) { + throw new LogicException('The configured SSR gateway does not support disabling server-side rendering conditionally.'); + } + + $gateway->disable($condition); + } + + /** + * Exclude the given paths from server-side rendering. + * + * @param array|string $paths + */ + public function withoutSsr(array|string $paths): void + { + $gateway = app(Gateway::class); + + if (! $gateway instanceof ExcludesSsrPaths) { + throw new LogicException('The configured SSR gateway does not support excluding paths from server-side rendering.'); + } + + $gateway->except($paths); + } + + /** + * Create an optional property. + */ + public function optional(callable $callback): OptionalProp + { + return new OptionalProp($callback); + } + + /** + * Create a deferred property. + */ + public function defer(callable $callback, string $group = 'default'): DeferProp + { + return new DeferProp($callback, $group); + } + + /** + * Create a merge property. + */ + public function merge(mixed $value): MergeProp + { + return new MergeProp($value); + } + + /** + * Create a deep merge property. + */ + public function deepMerge(mixed $value): MergeProp + { + return (new MergeProp($value))->deepMerge(); + } + + /** + * Create an always property. + */ + public function always(mixed $value): AlwaysProp + { + return new AlwaysProp($value); + } + + /** + * Create a scroll property. + * + * @template T + * + * @param T $value + * @return ScrollProp + */ + public function scroll(mixed $value, string $wrapper = 'data', ProvidesScrollMetadata|callable|null $metadata = null): ScrollProp + { + return new ScrollProp($value, $wrapper, $metadata); + } + + /** + * Create a once property. + */ + public function once(callable $value): OnceProp + { + return new OnceProp($value); + } + + /** + * Create and share a once property. + */ + public function shareOnce(string $key, callable $callback): OnceProp + { + return tap(new OnceProp($callback), fn ($prop) => $this->share($key, $prop)); + } + + /** + * Find the component or fail. + * + * @throws ComponentNotFoundException + */ + protected function findComponentOrFail(string $component): void + { + try { + app('inertia.view-finder')->find($component); + } catch (InvalidArgumentException) { + throw new ComponentNotFoundException("Inertia page component [{$component}] not found."); + } + } + + /** + * Transform the component name. + */ + protected function transformComponent(mixed $component): mixed + { + $transformer = $this->state()->componentTransformer; + + if (! $transformer) { + return $component; + } + + return $transformer($component) ?? $component; + } + + /** + * Create an Inertia response. + * + * @param BackedEnum|string|UnitEnum $component + * @param array|Arrayable|ProvidesInertiaProperties $props + */ + public function render(mixed $component, mixed $props = []): Response + { + $component = $this->transformComponent($component); + + $component = match (true) { + $component instanceof BackedEnum => $component->value, + $component instanceof UnitEnum => $component->name, + default => $component, + }; + + if (! is_string($component)) { + throw new InvalidArgumentException('Component argument must be of type string or a string BackedEnum'); + } + + if (config('inertia.pages.ensure_pages_exist', false)) { + $this->findComponentOrFail($component); + } + + if ($props instanceof Arrayable) { + $props = $props->toArray(); + } elseif ($props instanceof ProvidesInertiaProperties) { + // Will be resolved in PropsResolver::resolvePropertyProviders() + $props = [$props]; + } + + $state = $this->state(); + + return new Response( + $component, + $state->sharedProps, + $props, + $state->rootView, + $this->getVersion(), + $state->encryptHistory ?? (bool) config('inertia.history.encrypt', false), + $state->urlResolver, + ); + } + + /** + * Create an Inertia location response. + */ + public function location(string|RedirectResponse $url): SymfonyResponse + { + if (Request::inertia()) { + return BaseResponse::make('', 409, [Header::LOCATION => $url instanceof RedirectResponse ? $url->getTargetUrl() : $url]); + } + + return $url instanceof RedirectResponse ? $url : Redirect::away($url); + } + + /** + * Register a callback to handle HTTP exceptions for Inertia requests. + * + * This is boot-time configuration — call once in a service provider's + * boot() method. The callback is stored on the exception handler singleton + * and applies to all requests for the worker lifetime. + */ + public function handleExceptionsUsing(callable $callback): void + { + /** @var mixed $handler */ + $handler = app(ExceptionHandlerContract::class); + + if (! $handler instanceof ExceptionHandler) { + if (app()->runningInConsole()) { + return; + } + + if (! method_exists($handler, 'respondUsing')) { + throw new LogicException('The bound exception handler does not have a `respondUsing` method.'); + } + } + + /** @var ExceptionHandler $handler */ + $handler->respondUsing(function ($response, $e, $request) use ($callback) { + $result = $callback(new ExceptionResponse( + $e, + $request, + $response, + app(Router::class), + app(Kernel::class), + )); + + if ($result instanceof ExceptionResponse) { + return $result->toResponse($request); + } + + return $result ?? $response; + }); + } + + /** + * Flash data to be included with the next response. Unlike regular props, + * flash data is not persisted in the browser's history state, making it + * ideal for one-time notifications like toasts or highlights. + * + * @param array|BackedEnum|string|UnitEnum $key + */ + public function flash(BackedEnum|UnitEnum|string|array $key, mixed $value = null): self + { + $flash = $key; + + if (! is_array($key)) { + $key = match (true) { + $key instanceof BackedEnum => $key->value, + $key instanceof UnitEnum => $key->name, + default => $key, + }; + + $flash = [$key => $value]; + } + + session()->flash(SessionKey::FLASH_DATA, [ + ...$this->getFlashed(), + ...$flash, + ]); + + return $this; + } + + /** + * Create a new redirect response to the previous location. + * + * @param array $headers + */ + public function back(int $status = 302, array $headers = [], mixed $fallback = false): RedirectResponse + { + return Redirect::back($status, $headers, $fallback); + } + + /** + * Retrieve the flashed data from the session. + * + * @return array + */ + public function getFlashed(?HttpRequest $request = null): array + { + $request ??= request(); + + return $request->hasSession() ? $request->session()->get(SessionKey::FLASH_DATA, []) : []; + } + + /** + * Retrieve and remove the flashed data from the session. + * + * @return array + */ + public function pullFlashed(?HttpRequest $request = null): array + { + $request ??= request(); + + return $request->hasSession() ? $request->session()->pull(SessionKey::FLASH_DATA, []) : []; + } + + /** + * Reset the per-request Inertia state. + */ + public static function flushState(): void + { + CoroutineContext::forget(InertiaState::CONTEXT_KEY); + } +} diff --git a/src/inertia/src/ScrollMetadata.php b/src/inertia/src/ScrollMetadata.php new file mode 100644 index 000000000..b455a4072 --- /dev/null +++ b/src/inertia/src/ScrollMetadata.php @@ -0,0 +1,102 @@ + + */ +class ScrollMetadata implements Arrayable, ProvidesScrollMetadata +{ + /** + * Create a new scroll metadata instance. + */ + public function __construct( + protected readonly string $pageName, + protected readonly int|string|null $previousPage = null, + protected readonly int|string|null $nextPage = null, + protected readonly int|string|null $currentPage = null, + ) { + } + + /** + * Create a scroll metadata instance from a paginator. + */ + public static function fromPaginator(mixed $value): self + { + $paginator = $value instanceof JsonResource ? $value->resource : $value; + + if ($paginator instanceof CursorPaginator) { + return new self( + $cursorName = $paginator->getCursorName(), + $paginator->previousCursor()?->encode(), + $paginator->nextCursor()?->encode(), + $paginator->onFirstPage() ? 1 : (CursorPaginator::resolveCurrentCursor($cursorName)?->encode() ?? 1) + ); + } + + if ($paginator instanceof LengthAwarePaginator || $paginator instanceof Paginator) { + return new self( + $paginator->getPageName(), + $paginator->currentPage() > 1 ? $paginator->currentPage() - 1 : null, + $paginator->hasMorePages() ? $paginator->currentPage() + 1 : null, + $paginator->currentPage(), + ); + } + + throw new InvalidArgumentException('The given value is not a Hypervel paginator instance. Use a custom callback to extract pagination metadata.'); + } + + /** + * Get the page name parameter. + */ + public function getPageName(): string + { + return $this->pageName; + } + + /** + * Get the previous page identifier. + */ + public function getPreviousPage(): int|string|null + { + return $this->previousPage; + } + + /** + * Get the next page identifier. + */ + public function getNextPage(): int|string|null + { + return $this->nextPage; + } + + /** + * Get the current page identifier. + */ + public function getCurrentPage(): int|string|null + { + return $this->currentPage; + } + + /** + * Convert the scroll metadata instance to an array. + */ + public function toArray(): array + { + return [ + 'pageName' => $this->getPageName(), + 'previousPage' => $this->getPreviousPage(), + 'nextPage' => $this->getNextPage(), + 'currentPage' => $this->getCurrentPage(), + ]; + } +} diff --git a/src/inertia/src/ScrollProp.php b/src/inertia/src/ScrollProp.php new file mode 100644 index 000000000..732928e75 --- /dev/null +++ b/src/inertia/src/ScrollProp.php @@ -0,0 +1,131 @@ +merge = true; + $this->value = $value; + $this->wrapper = $wrapper; + $this->metadata = $metadata; + } + + /** + * Configure the merge strategy based on the infinite scroll merge intent header. + * + * The frontend InfiniteScroll component sends its merge intent directly, + * eliminating the need for direction-based logic on the backend. + */ + public function configureMergeIntent(?Request $request = null): static + { + $request ??= request(); + + return $request->header(Header::INFINITE_SCROLL_MERGE_INTENT) === 'prepend' + ? $this->prepend($this->wrapper) + : $this->append($this->wrapper); + } + + /** + * Resolve the scroll metadata provider. + */ + protected function resolveMetadataProvider(): ProvidesScrollMetadata + { + if ($this->metadata instanceof ProvidesScrollMetadata) { + return $this->metadata; + } + + $value = $this(); + + if (is_null($this->metadata)) { + return ScrollMetadata::fromPaginator($value); + } + + return call_user_func($this->metadata, $value); + } + + /** + * Get the pagination meta information. + * + * @return array{pageName: string, previousPage: null|int|string, nextPage: null|int|string, currentPage: null|int|string} + */ + public function metadata(): array + { + $metadataProvider = $this->resolveMetadataProvider(); + + return [ + 'pageName' => $metadataProvider->getPageName(), + 'previousPage' => $metadataProvider->getPreviousPage(), + 'nextPage' => $metadataProvider->getNextPage(), + 'currentPage' => $metadataProvider->getCurrentPage(), + ]; + } + + /** + * Resolve the property value. + * + * @return T + */ + public function __invoke(): mixed + { + if (isset($this->resolved)) { + return $this->resolved; + } + + return $this->resolved = $this->resolveCallable($this->value); + } +} diff --git a/src/inertia/src/Ssr/BundleDetector.php b/src/inertia/src/Ssr/BundleDetector.php new file mode 100644 index 000000000..0165783cb --- /dev/null +++ b/src/inertia/src/Ssr/BundleDetector.php @@ -0,0 +1,61 @@ +findBundle(); + self::$bundleDetected = true; + } + + return self::$cachedBundle; + } + + /** + * Search candidate paths for the SSR bundle file. + */ + private function findBundle(): ?string + { + return collect([ + config('inertia.ssr.bundle'), + base_path('bootstrap/ssr/ssr.js'), + base_path('bootstrap/ssr/app.js'), + base_path('bootstrap/ssr/ssr.mjs'), + base_path('bootstrap/ssr/app.mjs'), + public_path('js/ssr.js'), + public_path('js/app.js'), + ])->filter()->first(function ($path) { + return file_exists($path); + }); + } + + /** + * Reset the cached bundle detection state. + */ + public static function flushState(): void + { + self::$cachedBundle = null; + self::$bundleDetected = false; + } +} diff --git a/src/inertia/src/Ssr/DisablesSsr.php b/src/inertia/src/Ssr/DisablesSsr.php new file mode 100644 index 000000000..cb40d1ec6 --- /dev/null +++ b/src/inertia/src/Ssr/DisablesSsr.php @@ -0,0 +1,15 @@ +|string $paths + */ + public function except(array|string $paths): void; +} diff --git a/src/inertia/src/Ssr/Gateway.php b/src/inertia/src/Ssr/Gateway.php new file mode 100644 index 000000000..2504eb05a --- /dev/null +++ b/src/inertia/src/Ssr/Gateway.php @@ -0,0 +1,15 @@ + $page + */ + public function dispatch(array $page): ?Response; +} diff --git a/src/inertia/src/Ssr/HasHealthCheck.php b/src/inertia/src/Ssr/HasHealthCheck.php new file mode 100644 index 000000000..3e84a1e2a --- /dev/null +++ b/src/inertia/src/Ssr/HasHealthCheck.php @@ -0,0 +1,13 @@ + new InertiaState); + } + + /** + * Dispatch the Inertia page to the SSR engine via HTTP. + * + * @param array $page + */ + public function dispatch(array $page, ?Request $request = null): ?Response + { + if (! $this->ssrIsEnabled($request ?? request())) { + return null; + } + + $isHot = Vite::isRunningHot(); + + if (! $isHot && $this->shouldEnsureBundleExists() && ! $this->bundleExists()) { + return null; + } + + $url = $isHot + ? $this->getHotUrl('/__inertia_ssr') + : $this->getProductionUrl('/render'); + + $connectTimeout = (int) config('inertia.ssr.connect_timeout', 2); + $timeout = (int) config('inertia.ssr.timeout', 5); + + try { + $response = Http::connectTimeout($connectTimeout) + ->timeout($timeout) + ->post($url, $page); + + if ($response->failed()) { + $this->handleSsrFailure($page, $response->json()); + + return null; + } + + if (! $data = $response->json()) { + return null; + } + + // SSR succeeded — clear any previous backoff + self::$ssrUnavailableUntil = null; + + return new Response( + implode("\n", $data['head'] ?? []), + $data['body'] ?? '' + ); + } catch (Exception $e) { + if ($e instanceof StrayRequestException || $e instanceof SsrException) { + throw $e; + } + + $this->handleSsrFailure($page, [ + 'error' => $e->getMessage(), + 'type' => 'connection', + ]); + + return null; + } + } + + /** + * Set the condition that determines if SSR should be disabled. + */ + public function disable(Closure|bool $condition): void + { + $this->state()->ssrDisabled = $condition; + } + + /** + * Exclude the given paths from server-side rendering. + * + * @param array|string $paths + */ + public function except(array|string $paths): void + { + $state = $this->state(); + $state->ssrExcludedPaths = array_merge($state->ssrExcludedPaths, Arr::wrap($paths)); + } + + /** + * Get the paths excluded from SSR for the current request. + * + * Overrides ExcludesPaths::getExcludedPaths() to read from + * per-request InertiaState instead of an instance property. + * + * @return array + */ + public function getExcludedPaths(): array + { + return $this->state()->ssrExcludedPaths; + } + + /** + * Handle an SSR rendering failure. + * + * Sets the circuit breaker backoff and dispatches a failure event. + * + * @param array $page + * + * @throws SsrException + */ + protected function handleSsrFailure(array $page, mixed $error): void + { + // Normalize: json() returns mixed — scalar/null responses become empty array + // so the ?? defaults below produce a clean SsrRenderFailed event. + $error = is_array($error) ? $error : []; + + // Activate circuit breaker to avoid pile-up on a dead SSR server + self::$ssrUnavailableUntil = microtime(true) + (float) config('inertia.ssr.backoff', 5.0); + + $event = new SsrRenderFailed( + page: $page, + error: $error['error'] ?? 'Unknown SSR error', + type: SsrErrorType::fromString($error['type'] ?? null), + hint: $error['hint'] ?? null, + browserApi: $error['browserApi'] ?? null, + stack: $error['stack'] ?? null, + sourceLocation: $error['sourceLocation'] ?? null, + ); + + // Dispatch the already-built event directly (avoids double construction) + event($event); + + // Throw an exception if configured (useful for E2E testing) + if (config('inertia.ssr.throw_on_error', false)) { + throw SsrException::fromEvent($event); + } + } + + /** + * Determine if the SSR feature is enabled. + */ + protected function ssrIsEnabled(Request $request): bool + { + // Circuit breaker: skip SSR if recently failed + if (self::$ssrUnavailableUntil !== null && microtime(true) < self::$ssrUnavailableUntil) { + return false; + } + + $state = $this->state(); + + $enabled = $state->ssrDisabled !== null + ? ! $this->resolveCallable($state->ssrDisabled) + : config('inertia.ssr.enabled', true); + + return $enabled && ! $this->inExceptArray($request); + } + + /** + * Determine if the SSR server is healthy. + */ + public function isHealthy(): bool + { + $connectTimeout = (int) config('inertia.ssr.connect_timeout', 2); + $timeout = (int) config('inertia.ssr.timeout', 5); + + try { + return Http::connectTimeout($connectTimeout) + ->timeout($timeout) + ->get($this->getProductionUrl('/health')) + ->successful(); + } catch (Exception $e) { + if ($e instanceof StrayRequestException) { + throw $e; + } + + return false; + } + } + + /** + * Determine if the bundle existence should be ensured. + */ + protected function shouldEnsureBundleExists(): bool + { + return (bool) config('inertia.ssr.ensure_bundle_exists', true); + } + + /** + * Check if an SSR bundle exists. + */ + protected function bundleExists(): bool + { + return app(BundleDetector::class)->detect() !== null; + } + + /** + * Get the production SSR server URL. + */ + public function getProductionUrl(string $path = '/'): string + { + $path = Str::start($path, '/'); + $baseUrl = rtrim((string) config('inertia.ssr.url', 'http://127.0.0.1:13714'), '/'); + + return $baseUrl . $path; + } + + /** + * Get the Vite hot SSR URL. + */ + protected function getHotUrl(string $path = '/'): string + { + return rtrim(file_get_contents(Vite::hotFile())) . $path; + } + + /** + * Reset the circuit breaker state. + */ + public static function flushState(): void + { + self::$ssrUnavailableUntil = null; + } +} diff --git a/src/inertia/src/Ssr/Response.php b/src/inertia/src/Ssr/Response.php new file mode 100644 index 000000000..1e9c47b6e --- /dev/null +++ b/src/inertia/src/Ssr/Response.php @@ -0,0 +1,20 @@ +component(), + $event->error + ); + + if ($event->sourceLocation) { + $message .= sprintf(' at %s', $event->sourceLocation); + } + + $exception = new self($message); + $exception->event = $event; + + return $exception; + } + + /** + * The SSR render failed event containing error details. + */ + public ?SsrRenderFailed $event = null; + + /** + * Get the component that failed to render. + */ + public function component(): ?string + { + return $this->event?->component(); + } + + /** + * Get the error type. + */ + public function type(): ?SsrErrorType + { + return $this->event?->type; + } + + /** + * Get the hint for fixing the error. + */ + public function hint(): ?string + { + return $this->event?->hint; + } + + /** + * Get the source location where the error occurred. + */ + public function sourceLocation(): ?string + { + return $this->event?->sourceLocation; + } +} diff --git a/src/inertia/src/Ssr/SsrRenderFailed.php b/src/inertia/src/Ssr/SsrRenderFailed.php new file mode 100644 index 000000000..affcd2faa --- /dev/null +++ b/src/inertia/src/Ssr/SsrRenderFailed.php @@ -0,0 +1,68 @@ + $page The page data that was being rendered + * @param string $error The error message + * @param SsrErrorType $type The error type + * @param null|string $hint A helpful hint on how to fix the error + * @param null|string $browserApi The browser API that was accessed (if type is browser-api) + * @param null|string $stack The stack trace + * @param null|string $sourceLocation The source location (file:line:column) where the error occurred + */ + public function __construct( + public readonly array $page, + public readonly string $error, + public readonly SsrErrorType $type = SsrErrorType::Unknown, + public readonly ?string $hint = null, + public readonly ?string $browserApi = null, + public readonly ?string $stack = null, + public readonly ?string $sourceLocation = null, + ) { + } + + /** + * Get the component name from the page data. + */ + public function component(): string + { + return $this->page['component'] ?? 'Unknown'; + } + + /** + * Get the URL from the page data. + */ + public function url(): string + { + return $this->page['url'] ?? '/'; + } + + /** + * Convert the event to an array for logging. + * + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'component' => $this->component(), + 'url' => $this->url(), + 'error' => $this->error, + 'type' => $this->type->value, + 'hint' => $this->hint, + 'browser_api' => $this->browserApi, + 'source_location' => $this->sourceLocation, + ]); + } +} diff --git a/src/inertia/src/Support/Header.php b/src/inertia/src/Support/Header.php new file mode 100644 index 000000000..6823e6643 --- /dev/null +++ b/src/inertia/src/Support/Header.php @@ -0,0 +1,63 @@ +> + */ + private array $deferredProps; + + /** + * The Flash Data (if any). + * + * @var array + */ + private array $flash; + + /** + * Create an AssertableInertia instance from a test response. + * + * @param TestResponse $response + */ + public static function fromTestResponse(TestResponse $response): self + { + try { + $response->assertViewHas('page'); + $page = json_decode(json_encode($response->viewData('page')), true); + + PHPUnit::assertIsArray($page); + PHPUnit::assertArrayHasKey('component', $page); + PHPUnit::assertArrayHasKey('props', $page); + PHPUnit::assertArrayHasKey('url', $page); + PHPUnit::assertArrayHasKey('version', $page); + } catch (AssertionFailedError $e) { + PHPUnit::fail('Not a valid Inertia response.'); + } + + $instance = static::fromArray($page['props']); + $instance->component = $page['component']; + $instance->url = $page['url']; + $instance->version = $page['version']; + $instance->encryptHistory = isset($page['encryptHistory']); + $instance->clearHistory = isset($page['clearHistory']); + $instance->deferredProps = $page['deferredProps'] ?? []; + $instance->flash = $page['flash'] ?? []; + + return $instance; + } + + /** + * Assert that the page uses the given component. + * + * @param null|bool $shouldExist + */ + public function component(?string $value = null, $shouldExist = null): self + { + PHPUnit::assertSame($value, $this->component, 'Unexpected Inertia page component.'); + + if ($shouldExist || (is_null($shouldExist) && config('inertia.testing.ensure_pages_exist', true))) { + try { + app('inertia.view-finder')->find($value); + } catch (InvalidArgumentException $exception) { + PHPUnit::fail(sprintf('Inertia page component file [%s] does not exist.', $value)); + } + } + + return $this; + } + + /** + * Assert that the current page URL matches the expected value. + */ + public function url(string $value): self + { + PHPUnit::assertSame($value, $this->url, 'Unexpected Inertia page url.'); + + return $this; + } + + /** + * Assert that the current asset version matches the expected value. + */ + public function version(string $value): self + { + PHPUnit::assertSame($value, $this->version, 'Unexpected Inertia asset version.'); + + return $this; + } + + /** + * Load the deferred props for the given groups and perform assertions on the response. + * + * @param array|Closure|string $groupsOrCallback + */ + public function loadDeferredProps(Closure|array|string $groupsOrCallback, ?Closure $callback = null): self + { + $callback = is_callable($groupsOrCallback) ? $groupsOrCallback : $callback; + + $groups = is_callable($groupsOrCallback) ? array_keys($this->deferredProps) : Arr::wrap($groupsOrCallback); + + $props = collect($groups)->flatMap(function ($group) { + return $this->deferredProps[$group] ?? []; + })->implode(','); + + return $this->reloadOnly($props, $callback); + } + + /** + * Reload the Inertia page and perform assertions on the response. + * + * @param null|array|string $only + * @param null|array|string $except + */ + public function reload(?Closure $callback = null, array|string|null $only = null, array|string|null $except = null): self + { + if (is_array($only)) { + $only = implode(',', $only); + } + + if (is_array($except)) { + $except = implode(',', $except); + } + + $reloadRequest = new ReloadRequest( + $this->url, + $this->component, + $this->version, + $only, + $except, + ); + + $assertable = AssertableInertia::fromTestResponse($reloadRequest()); + + // Make sure we get the same data as the original request. + $assertable->component($this->component); + $assertable->url($this->url); + $assertable->version($this->version); + + if ($callback) { + $callback($assertable); + } + + return $this; + } + + /** + * Reload the Inertia page as a partial request with only the specified props. + * + * @param array|string $only + */ + public function reloadOnly(array|string $only, ?Closure $callback = null): self + { + return $this->reload(only: $only, callback: function (AssertableInertia $assertable) use ($only, $callback) { + $props = is_array($only) ? $only : explode(',', $only); + + $assertable->hasAll($props); + + if ($callback) { + $callback($assertable); + } + }); + } + + /** + * Reload the Inertia page as a partial request excluding the specified props. + * + * @param array|string $except + */ + public function reloadExcept(array|string $except, ?Closure $callback = null): self + { + return $this->reload(except: $except, callback: function (AssertableInertia $assertable) use ($except, $callback) { + $props = is_array($except) ? $except : explode(',', $except); + + $assertable->missingAll($props); + + if ($callback) { + $callback($assertable); + } + }); + } + + /** + * Assert that the Flash Data contains the given key, optionally with the expected value. + */ + public function hasFlash(string $key, mixed $expected = null): self + { + func_num_args() > 1 + ? static::assertFlashHas($this->flash, $key, $expected) + : static::assertFlashHas($this->flash, $key); + + return $this; + } + + /** + * Assert that the Flash Data does not contain the given key. + */ + public function missingFlash(string $key): self + { + static::assertFlashMissing($this->flash, $key); + + return $this; + } + + /** + * Assert that the given flash data array contains the given key, optionally with the expected value. + * + * @param array $flash + */ + public static function assertFlashHas(array $flash, string $key, mixed $expected = null): void + { + PHPUnit::assertTrue( + Arr::has($flash, $key), + sprintf('Inertia Flash Data is missing key [%s].', $key) + ); + + if (func_num_args() > 2) { + PHPUnit::assertSame( + $expected, + Arr::get($flash, $key), + sprintf('Inertia Flash Data [%s] does not match expected value.', $key) + ); + } + } + + /** + * Assert that the given flash data array does not contain the given key. + * + * @param array $flash + */ + public static function assertFlashMissing(array $flash, string $key): void + { + PHPUnit::assertFalse( + Arr::has($flash, $key), + sprintf('Inertia Flash Data has unexpected key [%s].', $key) + ); + } + + /** + * Convert the instance to an array. + * + * @return array + */ + public function toArray(): array + { + return array_merge( + [ + 'component' => $this->component, + 'props' => $this->prop(), + 'url' => $this->url, + 'version' => $this->version, + 'flash' => $this->flash, + ], + $this->encryptHistory ? ['encryptHistory' => true] : [], + $this->clearHistory ? ['clearHistory' => true] : [], + ); + } +} diff --git a/src/inertia/src/Testing/ReloadRequest.php b/src/inertia/src/Testing/ReloadRequest.php new file mode 100644 index 000000000..2ab79dc4e --- /dev/null +++ b/src/inertia/src/Testing/ReloadRequest.php @@ -0,0 +1,52 @@ +app ??= app(); + } + + /** + * Execute the reload request with appropriate Inertia headers. + * + * @return TestResponse + */ + public function __invoke(): TestResponse + { + $headers = [Header::VERSION => $this->version]; + + if (! blank($this->only)) { + $headers[Header::PARTIAL_COMPONENT] = $this->component; + $headers[Header::PARTIAL_ONLY] = $this->only; + } + + if (! blank($this->except)) { + $headers[Header::PARTIAL_COMPONENT] = $this->component; + $headers[Header::PARTIAL_EXCEPT] = $this->except; + } + + return $this->get($this->url, $headers); + } +} diff --git a/src/inertia/src/Testing/TestResponseMacros.php b/src/inertia/src/Testing/TestResponseMacros.php new file mode 100644 index 000000000..609f21b85 --- /dev/null +++ b/src/inertia/src/Testing/TestResponseMacros.php @@ -0,0 +1,87 @@ +toArray(); + }; + } + + /** + * Register the 'inertiaProps' macro for TestResponse. + */ + public function inertiaProps(): Closure + { + return function (?string $propName = null) { + /** @phpstan-ignore-next-line */ + $page = AssertableInertia::fromTestResponse($this)->toArray(); + + return Arr::get($page['props'], $propName); + }; + } + + /** + * Register the 'assertInertiaFlash' macro for TestResponse. + */ + public function assertInertiaFlash(): Closure + { + return function (string $key, mixed $expected = null) { + /** @phpstan-ignore-next-line */ + $flash = $this->session()->get(SessionKey::FLASH_DATA, []); + + func_num_args() > 1 + ? AssertableInertia::assertFlashHas($flash, $key, $expected) + : AssertableInertia::assertFlashHas($flash, $key); + + return $this; + }; + } + + /** + * Register the 'assertInertiaFlashMissing' macro for TestResponse. + */ + public function assertInertiaFlashMissing(): Closure + { + return function (string $key) { + /** @phpstan-ignore-next-line */ + $flash = $this->session()->get(SessionKey::FLASH_DATA, []); + + AssertableInertia::assertFlashMissing($flash, $key); + + return $this; + }; + } +} diff --git a/src/inertia/src/View/Components/App.php b/src/inertia/src/View/Components/App.php new file mode 100644 index 000000000..bfd3358c6 --- /dev/null +++ b/src/inertia/src/View/Components/App.php @@ -0,0 +1,46 @@ + new InertiaState); + + if (! $state->ssrDispatched) { + $state->ssrDispatched = true; + $state->ssrResponse = app(Gateway::class)->dispatch($state->page); + } + + $this->response = $state->ssrResponse; + $this->pageJson = (string) json_encode($state->page); + } + + /** + * Render the component. + */ + public function render(): string + { + return <<<'blade' +@if($response) +{!! $response->body !!} +@else +
+@endif +blade; + } +} diff --git a/src/inertia/src/View/Components/Head.php b/src/inertia/src/View/Components/Head.php new file mode 100644 index 000000000..cfe802420 --- /dev/null +++ b/src/inertia/src/View/Components/Head.php @@ -0,0 +1,42 @@ + new InertiaState); + + if (! $state->ssrDispatched) { + $state->ssrDispatched = true; + $state->ssrResponse = app(Gateway::class)->dispatch($state->page); + } + + $this->response = $state->ssrResponse; + } + + /** + * Render the component. + */ + public function render(): string + { + return <<<'blade' +@if($response) +{!! $response->head !!} +@else +{!! $slot !!} +@endif +blade; + } +} diff --git a/src/inertia/src/helpers.php b/src/inertia/src/helpers.php new file mode 100644 index 000000000..b9eb4fe04 --- /dev/null +++ b/src/inertia/src/helpers.php @@ -0,0 +1,40 @@ +|Arrayable $props + * @return ($component is null ? ResponseFactory : Response) + */ + function inertia(?string $component = null, array|Arrayable $props = []): ResponseFactory|Response + { + $instance = Inertia::getFacadeRoot(); + + if ($component) { + return $instance->render($component, $props); + } + + return $instance; + } +} + +if (! function_exists('inertia_location')) { + /** + * Inertia location helper. + */ + function inertia_location(string $url): SymfonyResponse + { + $instance = Inertia::getFacadeRoot(); + + return $instance->location($url); + } +} diff --git a/src/inertia/stubs/middleware.stub b/src/inertia/stubs/middleware.stub new file mode 100644 index 000000000..dae7c729d --- /dev/null +++ b/src/inertia/stubs/middleware.stub @@ -0,0 +1,42 @@ + + */ + public function share(Request $request): array + { + return [ + ...parent::share($request), + // + ]; + } +} diff --git a/src/support/src/Facades/Blade.php b/src/support/src/Facades/Blade.php index c7a9a641c..06ecdca12 100644 --- a/src/support/src/Facades/Blade.php +++ b/src/support/src/Facades/Blade.php @@ -27,7 +27,7 @@ * @method static void include(string $path, string|null $alias = null) * @method static void aliasInclude(string $path, string|null $alias = null) * @method static void bindDirective(string $name, callable $handler) - * @method static void directive(string $name, \Closure $handler, bool $bind = false) + * @method static void directive(string $name, callable $handler, bool $bind = false) * @method static array getCustomDirectives() * @method static \Hypervel\View\Compilers\BladeCompiler prepareStringsForCompilationUsing(callable $callback) * @method static void precompiler(callable $precompiler) diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index f821e04f9..29ce264f6 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -32,6 +32,11 @@ class TestCase extends BaseTestCase implements Contracts\TestCase { use Concerns\Testing; + /** + * The base URL to use while testing the application. + */ + protected string $baseUrl = 'http://localhost'; + /** * Automatically loads environment variables when available. */ @@ -60,6 +65,8 @@ protected function setUp(): void parent::setUp(); + $this->baseUrl = config('app.url', 'http://localhost'); + // Execute BeforeEach attributes INSIDE coroutine context // (matches where setUpTraits runs in Foundation TestCase) $this->runInCoroutine(fn () => $this->setUpTheTestEnvironmentUsingTestCase()); diff --git a/src/testing/src/AssertableJsonString.php b/src/testing/src/AssertableJsonString.php index 9f8c1053f..da98abc9e 100644 --- a/src/testing/src/AssertableJsonString.php +++ b/src/testing/src/AssertableJsonString.php @@ -317,7 +317,7 @@ protected function assertJsonMessage(array $data): string /** * Get the strings we need to search for when examining the JSON. */ - protected function jsonSearchStrings(string $key, mixed $value): array + protected function jsonSearchStrings(string|int $key, mixed $value): array { $needle = Str::substr(json_encode([$key => $value], JSON_UNESCAPED_UNICODE), 1, -1); diff --git a/src/view/src/Compilers/BladeCompiler.php b/src/view/src/Compilers/BladeCompiler.php index b165ab702..7cb1b5196 100644 --- a/src/view/src/Compilers/BladeCompiler.php +++ b/src/view/src/Compilers/BladeCompiler.php @@ -802,13 +802,13 @@ public function bindDirective(string $name, callable $handler): void * * @throws InvalidArgumentException */ - public function directive(string $name, Closure $handler, bool $bind = false): void + public function directive(string $name, callable $handler, bool $bind = false): void { if (! preg_match('/^\w+(?:::\w+)?$/x', $name)) { throw new InvalidArgumentException("The directive name [{$name}] is not valid. Directive names must only contain alphanumeric characters and underscores."); } - $this->customDirectives[$name] = $bind ? $handler->bindTo($this, BladeCompiler::class) : $handler; + $this->customDirectives[$name] = $bind ? Closure::fromCallable($handler)->bindTo($this, self::class) : $handler; } /** diff --git a/tests/AfterEachTestSubscriber.php b/tests/AfterEachTestSubscriber.php index 2e880813f..aafe7c70a 100644 --- a/tests/AfterEachTestSubscriber.php +++ b/tests/AfterEachTestSubscriber.php @@ -103,6 +103,10 @@ public function notify(Finished $event): void \Hypervel\Http\Middleware\TrustProxies::flushState(); \Hypervel\Http\Resources\Json\JsonResource::flushState(); \Hypervel\Http\Resources\JsonApi\JsonApiResource::flushState(); + \Hypervel\Inertia\Middleware::flushState(); + \Hypervel\Inertia\ResponseFactory::flushState(); + \Hypervel\Inertia\Ssr\BundleDetector::flushState(); + \Hypervel\Inertia\Ssr\HttpGateway::flushState(); \Hypervel\Log\Context\Repository::flushState(); \Hypervel\Mail\Attachment::flushState(); \Hypervel\Mail\Mailer::flushState(); diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 53b5d468c..720f19ead 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -358,6 +358,61 @@ public function testResponseBodyCasting() $this->assertSame(['foo' => 'bar'], $response['result']); } + public function testJsonCachesEmptyArray() + { + $this->factory->fake([ + '*' => '[]', + ]); + + $response = $this->factory->get('http://foo.com/api'); + + $this->assertSame([], $response->json()); + $this->assertSame([], $response->json()); + } + + public function testJsonCachesScalarValues() + { + $this->factory->fake([ + 'foo.com/zero' => '0', + 'foo.com/false' => 'false', + ]); + + $response = $this->factory->get('http://foo.com/zero'); + $this->assertSame(0, $response->json()); + $this->assertSame(0, $response->json()); + + $response = $this->factory->get('http://foo.com/false'); + $this->assertSame(false, $response->json()); + $this->assertSame(false, $response->json()); + } + + public function testJsonCachesNullForInvalidJson() + { + $this->factory->fake([ + '*' => 'not valid json', + ]); + + $response = $this->factory->get('http://foo.com/api'); + + $this->assertNull($response->json()); + $this->assertNull($response->json()); + } + + public function testDecodeUsingResetsCacheAndReDecodesWithNewCallback() + { + $this->factory->fake([ + '*' => '{"key":"value"}', + ]); + + $response = $this->factory->get('http://foo.com/api'); + + $this->assertSame(['key' => 'value'], $response->json()); + + $response->decodeUsing(fn (string $body, bool $asObject) => ['custom' => 'decoded']); + + $this->assertSame(['custom' => 'decoded'], $response->json()); + } + public function testResponseObjectAsArray() { $this->factory->fake([ diff --git a/tests/Inertia/AlwaysPropTest.php b/tests/Inertia/AlwaysPropTest.php new file mode 100644 index 000000000..25c35fb1c --- /dev/null +++ b/tests/Inertia/AlwaysPropTest.php @@ -0,0 +1,61 @@ +assertSame('An always value', $alwaysProp()); + } + + public function testCanAcceptScalarValues(): void + { + $alwaysProp = new AlwaysProp('An always value'); + + $this->assertSame('An always value', $alwaysProp()); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $alwaysProp = new AlwaysProp('date'); + + $this->assertSame('date', $alwaysProp()); + } + + public function testCanAcceptCallables(): void + { + $callable = new class { + public function __invoke(): string + { + return 'An always value'; + } + }; + + $alwaysProp = new AlwaysProp($callable); + + $this->assertSame('An always value', $alwaysProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $alwaysProp = new AlwaysProp(function (Request $request) { + return $request; + }); + + $this->assertInstanceOf(Request::class, $alwaysProp()); + } +} diff --git a/tests/Inertia/BundleDetectorTest.php b/tests/Inertia/BundleDetectorTest.php new file mode 100644 index 000000000..19eece321 --- /dev/null +++ b/tests/Inertia/BundleDetectorTest.php @@ -0,0 +1,45 @@ +set('inertia.ssr.bundle', __FILE__); + + $detector = new BundleDetector; + + $first = $detector->detect(); + $this->assertSame(__FILE__, $first); + + // Change config — cached result should still be returned + config()->set('inertia.ssr.bundle', '/nonexistent/path'); + + $second = $detector->detect(); + $this->assertSame(__FILE__, $second); + } + + public function testFlushStateResetsCache() + { + config()->set('inertia.ssr.bundle', __FILE__); + + $detector = new BundleDetector; + $detector->detect(); + + BundleDetector::flushState(); + + // After flush, config change is picked up + config()->set('inertia.ssr.bundle', null); + + $this->assertNull($detector->detect()); + } +} diff --git a/tests/Inertia/Commands/CheckSsrTest.php b/tests/Inertia/Commands/CheckSsrTest.php new file mode 100644 index 000000000..9a09f5d7a --- /dev/null +++ b/tests/Inertia/Commands/CheckSsrTest.php @@ -0,0 +1,48 @@ +shouldReceive('isHealthy')->andReturn(true); + $this->app->instance(Gateway::class, $mock); + + $this->artisan('inertia:check-ssr') + ->expectsOutput('Inertia SSR server is running.') + ->assertExitCode(0); + } + + public function testFailureOnUnhealthySsrServer(): void + { + $mock = Mockery::mock(Gateway::class, HasHealthCheck::class); + $mock->shouldReceive('isHealthy')->andReturn(false); + $this->app->instance(Gateway::class, $mock); + + $this->artisan('inertia:check-ssr') + ->expectsOutput('Inertia SSR server is not running.') + ->assertExitCode(1); + } + + public function testFailureOnUnsupportedGateway(): void + { + $this->mock(Gateway::class); + + $this->artisan('inertia:check-ssr') + ->expectsOutput('The SSR gateway does not support health checks.') + ->assertExitCode(1); + } +} diff --git a/tests/Inertia/Commands/StartSsrTest.php b/tests/Inertia/Commands/StartSsrTest.php new file mode 100644 index 000000000..cbe1a3712 --- /dev/null +++ b/tests/Inertia/Commands/StartSsrTest.php @@ -0,0 +1,149 @@ + */ + protected ?array $processCommand = null; + + protected function setUp(): void + { + parent::setUp(); + + config()->set('inertia.ssr.enabled', true); + config()->set('inertia.ssr.bundle', __FILE__); + } + + protected function fakeProcess(): void + { + $this->app->bind(Process::class, function ($app, $params) { + $this->processCommand = $params['command']; + + return new Process(['true']); + }); + } + + public function testErrorWhenSsrIsDisabled(): void + { + config()->set('inertia.ssr.enabled', false); + + $this->artisan('inertia:start-ssr') + ->expectsOutput('Inertia SSR is not enabled. Enable it via the `inertia.ssr.enabled` config option.') + ->assertExitCode(1); + } + + public function testErrorWhenConfiguredBundleNotFound(): void + { + config()->set('inertia.ssr.bundle', '/nonexistent/path/ssr.mjs'); + + $this->artisan('inertia:start-ssr') + ->expectsOutput('Inertia SSR bundle not found at the configured path: "/nonexistent/path/ssr.mjs"') + ->assertExitCode(1); + } + + public function testErrorWhenNoBundleConfiguredAndDetectionFails(): void + { + config()->set('inertia.ssr.bundle', null); + + $this->artisan('inertia:start-ssr') + ->expectsOutput('Inertia SSR bundle not found. Set the correct Inertia SSR bundle path in your `inertia.ssr.bundle` config.') + ->assertExitCode(1); + } + + public function testBundleIsAutoDetectedWhenNotConfigured(): void + { + $this->fakeProcess(); + config()->set('inertia.ssr.bundle', null); + + $bundlePath = base_path('bootstrap/ssr/ssr.mjs'); + @mkdir(dirname($bundlePath), recursive: true); + file_put_contents($bundlePath, ''); + + try { + $this->artisan('inertia:start-ssr')->assertExitCode(0); + + $this->assertSame($bundlePath, $this->processCommand[1]); + } finally { + @unlink($bundlePath); + @rmdir(base_path('bootstrap/ssr')); + } + } + + public function testRuntimeDefaultsToNode(): void + { + $this->fakeProcess(); + + $this->artisan('inertia:start-ssr')->assertExitCode(0); + + $this->assertSame('node', $this->processCommand[0]); + } + + public function testRuntimeCanBeConfigured(): void + { + $this->fakeProcess(); + config()->set('inertia.ssr.runtime', 'bun'); + + $this->artisan('inertia:start-ssr')->assertExitCode(0); + + $this->assertSame('bun', $this->processCommand[0]); + } + + public function testRuntimeCanBeAnAbsolutePath(): void + { + $this->fakeProcess(); + config()->set('inertia.ssr.runtime', '/usr/local/bin/node'); + + $this->artisan('inertia:start-ssr')->assertExitCode(0); + + $this->assertSame('/usr/local/bin/node', $this->processCommand[0]); + } + + public function testRuntimeOptionOverridesConfig(): void + { + $this->fakeProcess(); + config()->set('inertia.ssr.runtime', 'bun'); + + $this->artisan('inertia:start-ssr', ['--runtime' => '/custom/path/node'])->assertExitCode(0); + + $this->assertSame('/custom/path/node', $this->processCommand[0]); + } + + public function testEnsureRuntimeExistsFailsWhenRuntimeNotFound(): void + { + config()->set('inertia.ssr.ensure_runtime_exists', true); + config()->set('inertia.ssr.runtime', 'nonexistent-runtime-binary'); + + $this->artisan('inertia:start-ssr') + ->expectsOutput('SSR runtime "nonexistent-runtime-binary" could not be found.') + ->assertExitCode(1); + } + + public function testEnsureRuntimeExistsPassesWhenRuntimeFound(): void + { + $this->fakeProcess(); + config()->set('inertia.ssr.ensure_runtime_exists', true); + config()->set('inertia.ssr.runtime', 'php'); + + $this->artisan('inertia:start-ssr')->assertExitCode(0); + } + + public function testRuntimeIsNotCheckedByDefault(): void + { + $this->fakeProcess(); + config()->set('inertia.ssr.runtime', 'nonexistent-runtime-binary'); + + $this->artisan('inertia:start-ssr')->assertExitCode(0); + + $this->assertSame('nonexistent-runtime-binary', $this->processCommand[0]); + } +} diff --git a/tests/Inertia/ComponentTest.php b/tests/Inertia/ComponentTest.php new file mode 100644 index 000000000..fecafccb2 --- /dev/null +++ b/tests/Inertia/ComponentTest.php @@ -0,0 +1,215 @@ +app->bind(Gateway::class, FakeGateway::class); + } + + /** + * @param array $data + */ + protected function renderView(string $contents, array $data = []): string + { + $state = CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); + $state->page = $data['page'] ?? []; + + return Blade::render($contents, $data, true); + } + + /** + * Reset InertiaState between renders within the same test. + */ + protected function resetInertiaState(): void + { + CoroutineContext::forget(InertiaState::CONTEXT_KEY); + } + + public function testHeadComponentRendersFallbackSlotWhenSsrIsDisabled() + { + Config::set(['inertia.ssr.enabled' => false]); + + $view = 'Fallback Title'; + + $this->assertStringContainsString( + 'Fallback Title', + $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]) + ); + } + + public function testHeadComponentRendersSsrHeadWhenSsrIsEnabled() + { + Config::set(['inertia.ssr.enabled' => true]); + + $view = 'Fallback Title'; + $rendered = $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertStringContainsString('Example SSR Title', $rendered); + $this->assertStringNotContainsString('Fallback Title', $rendered); + } + + public function testAppComponentRendersClientSideDivWhenSsrIsDisabled() + { + Config::set(['inertia.ssr.enabled' => false]); + + $view = ''; + $rendered = $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertStringContainsString('
', $rendered); + $this->assertStringContainsString('data-page="app"', $rendered); + } + + public function testAppComponentRendersSsrBodyWhenSsrIsEnabled() + { + Config::set(['inertia.ssr.enabled' => true]); + + $view = ''; + + $this->assertSame( + '

This is some example SSR content

', + trim($this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT])) + ); + } + + public function testAppComponentAcceptsCustomId() + { + Config::set(['inertia.ssr.enabled' => false]); + + $view = ''; + $rendered = $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertStringContainsString('
', $rendered); + $this->assertStringContainsString('data-page="custom"', $rendered); + } + + public function testSsrIsOnlyDispatchedOnceWithComponents() + { + Config::set(['inertia.ssr.enabled' => true]); + $this->app->instance(Gateway::class, $gateway = new FakeGateway); + + $view = 'Fallback'; + $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertSame(1, $gateway->times); + } + + public function testAppComponentMatchesDirectiveOutputWhenSsrIsDisabled() + { + Config::set(['inertia.ssr.enabled' => false]); + + $directive = $this->renderView('@inertia', ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->resetInertiaState(); + + $component = trim($this->renderView('', ['page' => self::EXAMPLE_PAGE_OBJECT])); + + $this->assertSame($directive, $component); + } + + public function testAppComponentMatchesDirectiveOutputWhenSsrIsEnabled() + { + Config::set(['inertia.ssr.enabled' => true]); + + $directive = $this->renderView('@inertia', ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->resetInertiaState(); + + $component = trim($this->renderView('', ['page' => self::EXAMPLE_PAGE_OBJECT])); + + $this->assertSame($directive, $component); + } + + public function testAppComponentWithCustomIdMatchesDirectiveOutput() + { + Config::set(['inertia.ssr.enabled' => false]); + + $directive = $this->renderView('@inertia("foo")', ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->resetInertiaState(); + + $component = trim($this->renderView('', ['page' => self::EXAMPLE_PAGE_OBJECT])); + + $this->assertSame($directive, $component); + } + + public function testHeadComponentWithoutSlotMatchesDirectiveOutputWhenSsrIsDisabled() + { + Config::set(['inertia.ssr.enabled' => false]); + + $directive = $this->renderView('@inertiaHead', ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->resetInertiaState(); + + $component = trim($this->renderView('', ['page' => self::EXAMPLE_PAGE_OBJECT])); + + $this->assertSame($directive, $component); + } + + public function testHeadComponentWithoutSlotMatchesDirectiveOutputWhenSsrIsEnabled() + { + Config::set(['inertia.ssr.enabled' => true]); + + $directive = trim($this->renderView('@inertiaHead', ['page' => self::EXAMPLE_PAGE_OBJECT])); + + $this->resetInertiaState(); + + $component = trim($this->renderView('', ['page' => self::EXAMPLE_PAGE_OBJECT])); + + $this->assertSame($directive, $component); + } + + public function testComponentsDoNotCreateCachedViewFilesPerRequest() + { + Config::set(['inertia.ssr.enabled' => true]); + + $viewCachePath = $this->app['config']['view.compiled']; + $view = 'Fallback'; + + $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]); + $cachedViews = glob($viewCachePath . '/*.php'); + + $this->resetInertiaState(); + + $this->renderView($view, ['page' => ['component' => 'Different', 'props' => ['foo' => 'bar']]]); + $this->assertSame($cachedViews, glob($viewCachePath . '/*.php')); + } + + public function testInertiaStateDoesNotLeakBetweenRequests() + { + Config::set(['inertia.ssr.enabled' => true]); + + $state1 = CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); + $state1->page = self::EXAMPLE_PAGE_OBJECT; + $state1->ssrDispatched = true; + $state1->ssrResponse = app(Gateway::class)->dispatch($state1->page); + + $this->assertNotNull($state1->ssrResponse); + + // Simulate request boundary by clearing Context state + $this->resetInertiaState(); + + $state2 = CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); + + $this->assertNotSame($state1, $state2); + $this->assertNull($state2->ssrResponse); + $this->assertFalse($state2->ssrDispatched); + } +} diff --git a/tests/Inertia/ControllerTest.php b/tests/Inertia/ControllerTest.php new file mode 100644 index 000000000..77d3c2844 --- /dev/null +++ b/tests/Inertia/ControllerTest.php @@ -0,0 +1,40 @@ +get('/', Controller::class) + ->defaults('component', 'User/Edit') + ->defaults('props', [ + 'user' => ['name' => 'Jonathan'], + ]); + + $response = $this->get('/'); + + $this->assertEquals($response->viewData('page'), [ + 'component' => 'User/Edit', + 'props' => [ + 'user' => ['name' => 'Jonathan'], + 'errors' => (object) [], + ], + 'url' => '/', + 'version' => '', + 'sharedProps' => ['errors'], + ]); + } +} diff --git a/tests/Inertia/CoroutineIsolationTest.php b/tests/Inertia/CoroutineIsolationTest.php new file mode 100644 index 000000000..357accfe4 --- /dev/null +++ b/tests/Inertia/CoroutineIsolationTest.php @@ -0,0 +1,85 @@ +share('user', 'Alice'); + usleep(1000); + + return $factory->getShared('user'); + }, + function () { + $factory = new ResponseFactory; + $factory->share('user', 'Bob'); + usleep(1000); + + return $factory->getShared('user'); + }, + ]); + + $this->assertContains('Alice', $results); + $this->assertContains('Bob', $results); + $this->assertCount(2, $results); + } + + public function testRootViewIsIsolatedBetweenCoroutines() + { + $results = parallel([ + function () { + $factory = new ResponseFactory; + $factory->setRootView('layout-a'); + usleep(1000); + + return CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState)->rootView; + }, + function () { + $factory = new ResponseFactory; + $factory->setRootView('layout-b'); + usleep(1000); + + return CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState)->rootView; + }, + ]); + + $this->assertContains('layout-a', $results); + $this->assertContains('layout-b', $results); + } + + public function testInertiaStateIsDestroyedWhenCoroutineEnds() + { + // First parallel block: coroutine sets state then ends + parallel([ + function () { + $factory = new ResponseFactory; + $factory->share('key', 'value'); + }, + ]); + + // Second parallel block: new coroutine should not see the state + $results = parallel([ + function () { + return CoroutineContext::get(InertiaState::CONTEXT_KEY); + }, + ]); + + $this->assertNull($results[0]); + } +} diff --git a/tests/Inertia/DeepMergePropTest.php b/tests/Inertia/DeepMergePropTest.php new file mode 100644 index 000000000..4bbbc05b1 --- /dev/null +++ b/tests/Inertia/DeepMergePropTest.php @@ -0,0 +1,50 @@ + 'A merge prop value'))->deepMerge(); + + $this->assertSame('A merge prop value', $mergeProp()); + } + + public function testCanInvokeWithANonCallback(): void + { + $mergeProp = (new MergeProp(['key' => 'value']))->deepMerge(); + + $this->assertSame(['key' => 'value'], $mergeProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $mergeProp = (new MergeProp(fn (Request $request) => $request))->deepMerge(); + + $this->assertInstanceOf(Request::class, $mergeProp()); + } + + public function testCanUseSingleStringAsKeyToMatchOn(): void + { + $mergeProp = (new MergeProp(['key' => 'value']))->matchOn('key'); + + $this->assertEquals(['key'], $mergeProp->matchesOn()); + } + + public function testCanUseAnArrayOfStringsAsKeysToMatchOn(): void + { + $mergeProp = (new MergeProp(['key' => 'value']))->matchOn(['key', 'anotherKey']); + + $this->assertEquals(['key', 'anotherKey'], $mergeProp->matchesOn()); + } +} diff --git a/tests/Inertia/DeferPropTest.php b/tests/Inertia/DeferPropTest.php new file mode 100644 index 000000000..98188aa08 --- /dev/null +++ b/tests/Inertia/DeferPropTest.php @@ -0,0 +1,60 @@ +assertSame('A deferred value', $deferProp()); + $this->assertSame('default', $deferProp->group()); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $deferProp = new DeferProp('date'); + + $this->assertSame('date', $deferProp()); + } + + public function testCanInvokeAndMerge(): void + { + $deferProp = (new DeferProp(function () { + return 'A deferred value'; + }))->merge(); + + $this->assertSame('A deferred value', $deferProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $deferProp = new DeferProp(function (Request $request) { + return $request; + }); + + $this->assertInstanceOf(Request::class, $deferProp()); + } + + public function testIsOnceable(): void + { + $deferProp = (new DeferProp(fn () => 'value')) + ->once(as: 'custom-key', until: 60); + + $this->assertTrue($deferProp->shouldResolveOnce()); + $this->assertSame('custom-key', $deferProp->getKey()); + $this->assertNotNull($deferProp->expiresAt()); + } +} diff --git a/tests/Inertia/DirectiveTest.php b/tests/Inertia/DirectiveTest.php new file mode 100644 index 000000000..2da5760cd --- /dev/null +++ b/tests/Inertia/DirectiveTest.php @@ -0,0 +1,113 @@ +app->bind(Gateway::class, FakeGateway::class); + $this->filesystem = m::mock(Filesystem::class); + + /** @var Filesystem $filesystem */ + $filesystem = $this->filesystem; + $this->compiler = new BladeCompiler($filesystem, __DIR__ . '/cache/views'); + $this->compiler->directive('inertia', [Directive::class, 'compile']); + $this->compiler->directive('inertiaHead', [Directive::class, 'compileHead']); + } + + /** + * @param array $data + */ + protected function renderView(string $contents, array $data = []): string + { + return Blade::render($contents, $data, true); + } + + public function testInertiaDirectiveRendersTheRootElement(): void + { + Config::set(['inertia.ssr.enabled' => false]); + + $html = '
'; + + $this->assertSame($html, $this->renderView('@inertia', ['page' => self::EXAMPLE_PAGE_OBJECT])); + $this->assertSame($html, $this->renderView('@inertia()', ['page' => self::EXAMPLE_PAGE_OBJECT])); + $this->assertSame($html, $this->renderView('@inertia("")', ['page' => self::EXAMPLE_PAGE_OBJECT])); + $this->assertSame($html, $this->renderView("@inertia('')", ['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testInertiaDirectiveRendersServerSideRenderedContentWhenEnabled(): void + { + Config::set(['inertia.ssr.enabled' => true]); + + $this->assertSame( + '

This is some example SSR content

', + $this->renderView('@inertia', ['page' => self::EXAMPLE_PAGE_OBJECT]) + ); + } + + public function testInertiaDirectiveCanUseADifferentRootElementId(): void + { + Config::set(['inertia.ssr.enabled' => false]); + + $html = '
'; + + $this->assertSame($html, $this->renderView('@inertia(foo)', ['page' => self::EXAMPLE_PAGE_OBJECT])); + $this->assertSame($html, $this->renderView("@inertia('foo')", ['page' => self::EXAMPLE_PAGE_OBJECT])); + $this->assertSame($html, $this->renderView('@inertia("foo")', ['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testInertiaHeadDirectiveRendersNothing(): void + { + Config::set(['inertia.ssr.enabled' => false]); + + $this->assertEmpty($this->renderView('@inertiaHead', ['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testInertiaHeadDirectiveRendersServerSideRenderedHeadElementsWhenEnabled(): void + { + Config::set(['inertia.ssr.enabled' => true]); + + $this->assertSame( + "\nExample SSR Title\n", + $this->renderView('@inertiaHead', ['page' => self::EXAMPLE_PAGE_OBJECT]) + ); + } + + public function testTheServerSideRenderingRequestIsDispatchedOnlyOncePerRequest(): void + { + Config::set(['inertia.ssr.enabled' => true]); + $this->app->instance(Gateway::class, $gateway = new FakeGateway); + + $view = "\n\n\n@inertiaHead\n\n\n@inertia\n\n"; + $expected = "\n\n\n\nExample SSR Title\n\n\n

This is some example SSR content

\n"; + + $this->assertSame( + $expected, + $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]) + ); + + $this->assertSame(1, $gateway->times); + } +} diff --git a/tests/Inertia/ExceptionResponseTest.php b/tests/Inertia/ExceptionResponseTest.php new file mode 100644 index 000000000..b998aab27 --- /dev/null +++ b/tests/Inertia/ExceptionResponseTest.php @@ -0,0 +1,197 @@ +get('/', function () { + abort(500); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertStatus(500); + $this->assertFalse($response->headers->has('X-Inertia')); + } + + public function testExceptionsCanRenderInertiaPages(): void + { + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + return $response->render('Error', [ + 'status' => $response->statusCode(), + 'message' => $response->exception->getMessage(), + ]); + }); + + Route::middleware([StartSession::class, Middleware::class])->get('/', function () { + abort(500, 'Something went wrong'); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertStatus(500); + $response->assertJson([ + 'component' => 'Error', + 'props' => [ + 'status' => 500, + 'message' => 'Something went wrong', + ], + ]); + } + + public function testExceptionsCanReturnRedirects(): void + { + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + if ($response->statusCode() === 419) { + return redirect()->to('/login'); + } + + return $response->render('Error', ['status' => $response->statusCode()]); + }); + + Route::middleware([StartSession::class, Middleware::class])->get('/', function () { + abort(419); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertRedirect('/login'); + } + + public function testExceptionsCanFallThroughToDefault(): void + { + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + if ($response->statusCode() === 500) { + return null; + } + + return $response->render('Error', ['status' => $response->statusCode()]); + }); + + Route::middleware([StartSession::class, Middleware::class])->get('/', function () { + abort(500); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertStatus(500); + $this->assertFalse($response->headers->has('X-Inertia')); + } + + public function testExceptionsWithSharedData(): void + { + $kernel = $this->app->make(Kernel::class); + $kernel->appendMiddlewareToGroup('web', HttpExceptionMiddleware::class); + + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + return $response->render('Error', ['status' => $response->statusCode()]) + ->withSharedData(); + }); + + $response = $this->get('/non-existent-page', ['X-Inertia' => 'true']); + + $response->assertStatus(404); + $response->assertJson([ + 'component' => 'Error', + 'props' => [ + 'status' => 404, + 'appName' => 'My App', + ], + ]); + } + + public function testExceptionsWithSharedDataFromExplicitMiddleware(): void + { + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + return $response->render('Error', ['status' => $response->statusCode()]) + ->usingMiddleware(HttpExceptionMiddleware::class) + ->withSharedData(); + }); + + Route::middleware([StartSession::class, Middleware::class])->get('/', function () { + abort(500); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertStatus(500); + $response->assertJson([ + 'component' => 'Error', + 'props' => [ + 'status' => 500, + 'appName' => 'My App', + ], + ]); + } + + public function testExceptionsWithoutSharedData(): void + { + $kernel = $this->app->make(Kernel::class); + $kernel->appendMiddlewareToGroup('web', HttpExceptionMiddleware::class); + + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + return $response->render('Error', ['status' => $response->statusCode()]); + }); + + $response = $this->get('/non-existent-page', ['X-Inertia' => 'true']); + + $response->assertStatus(404); + $page = $response->json(); + $this->assertSame('Error', $page['component']); + $this->assertSame(404, $page['props']['status']); + $this->assertArrayNotHasKey('appName', $page['props']); + } + + public function testExceptionsWithCustomRootView(): void + { + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + return $response->render('Error', ['status' => $response->statusCode()]) + ->rootView('custom'); + }); + + Route::middleware([StartSession::class, Middleware::class])->get('/', function () { + abort(500); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertStatus(500); + $response->assertJson(['component' => 'Error']); + } + + public function testExceptionsOutsideMiddlewareAreHandled(): void + { + $kernel = $this->app->make(Kernel::class); + $kernel->appendMiddlewareToGroup('web', Middleware::class); + + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + return $response->render('Error', ['status' => $response->statusCode()]); + }); + + $response = $this->get('/non-existent-page', ['X-Inertia' => 'true']); + + $response->assertStatus(404); + $response->assertJson([ + 'component' => 'Error', + 'props' => [ + 'status' => 404, + ], + ]); + } +} diff --git a/tests/Inertia/Fixtures/CustomUrlResolverMiddleware.php b/tests/Inertia/Fixtures/CustomUrlResolverMiddleware.php new file mode 100644 index 000000000..b272a6d76 --- /dev/null +++ b/tests/Inertia/Fixtures/CustomUrlResolverMiddleware.php @@ -0,0 +1,24 @@ + +
This is an example Inertia page component.
+ diff --git a/tests/Inertia/Fixtures/ExampleInertiaPropsProvider.php b/tests/Inertia/Fixtures/ExampleInertiaPropsProvider.php new file mode 100644 index 000000000..3cc75d625 --- /dev/null +++ b/tests/Inertia/Fixtures/ExampleInertiaPropsProvider.php @@ -0,0 +1,27 @@ + $properties + */ + public function __construct( + protected array $properties + ) { + } + + /** + * @return iterable + */ + public function toInertiaProperties(RenderContext $context): iterable + { + return $this->properties; + } +} diff --git a/tests/Inertia/Fixtures/ExampleMiddleware.php b/tests/Inertia/Fixtures/ExampleMiddleware.php new file mode 100644 index 000000000..b293f9a73 --- /dev/null +++ b/tests/Inertia/Fixtures/ExampleMiddleware.php @@ -0,0 +1,61 @@ + $shared + */ + public function __construct(mixed $version = null, array $shared = []) + { + $this->version = $version; + $this->shared = $shared; + } + + /** + * Determines the current asset version. + * + * @see https://inertiajs.com/asset-versioning + */ + public function version(Request $request): ?string + { + return $this->version !== null ? (string) $this->version : null; + } + + /** + * Defines the props that are shared by default. + * + * @see https://inertiajs.com/shared-data + */ + public function share(Request $request): array + { + return array_merge(parent::share($request), $this->shared); + } + + /** + * Determines what to do when an Inertia action returned with no response. + * By default, we'll redirect the user back to where they came from. + */ + public function onEmptyResponse(Request $request, Response $response): Response + { + throw new LogicException('An empty Inertia response was returned.'); + } +} diff --git a/tests/Inertia/Fixtures/ExamplePage.vue b/tests/Inertia/Fixtures/ExamplePage.vue new file mode 100644 index 000000000..274922719 --- /dev/null +++ b/tests/Inertia/Fixtures/ExamplePage.vue @@ -0,0 +1,3 @@ + diff --git a/tests/Inertia/Fixtures/FakeGateway.php b/tests/Inertia/Fixtures/FakeGateway.php new file mode 100644 index 000000000..306aba044 --- /dev/null +++ b/tests/Inertia/Fixtures/FakeGateway.php @@ -0,0 +1,34 @@ +times; + + if (! Config::get('inertia.ssr.enabled', false)) { + return null; + } + + return new Response( + "\nExample SSR Title\n", + '

This is some example SSR content

' + ); + } +} diff --git a/tests/Inertia/Fixtures/FakeResource.php b/tests/Inertia/Fixtures/FakeResource.php new file mode 100644 index 000000000..ce86e5c1a --- /dev/null +++ b/tests/Inertia/Fixtures/FakeResource.php @@ -0,0 +1,42 @@ + + */ + private array $data; + + /** + * The "data" wrapper that should be applied. + */ + public static ?string $wrap = null; + + /** + * @param array $resource + */ + public function __construct(array $resource) + { + parent::__construct(null); + $this->data = $resource; + } + + /** + * Transform the resource into an array. + * + * @return array + */ + public function toArray(Request $request): array + { + return $this->data; + } +} diff --git a/tests/Inertia/Fixtures/HttpExceptionMiddleware.php b/tests/Inertia/Fixtures/HttpExceptionMiddleware.php new file mode 100644 index 000000000..bdb9b5d03 --- /dev/null +++ b/tests/Inertia/Fixtures/HttpExceptionMiddleware.php @@ -0,0 +1,18 @@ + 'My App', + ]); + } +} diff --git a/tests/Inertia/Fixtures/MergeWithSharedProp.php b/tests/Inertia/Fixtures/MergeWithSharedProp.php new file mode 100644 index 000000000..ad60c4aef --- /dev/null +++ b/tests/Inertia/Fixtures/MergeWithSharedProp.php @@ -0,0 +1,24 @@ + $items + */ + public function __construct(protected array $items = []) + { + } + + public function toInertiaProperty(PropertyContext $prop): mixed + { + return array_merge(Inertia::getShared($prop->key, []), $this->items); + } +} diff --git a/tests/Inertia/Fixtures/SsrExceptMiddleware.php b/tests/Inertia/Fixtures/SsrExceptMiddleware.php new file mode 100644 index 000000000..8cdc6b606 --- /dev/null +++ b/tests/Inertia/Fixtures/SsrExceptMiddleware.php @@ -0,0 +1,20 @@ + + */ + protected array $withoutSsr = [ + 'admin/*', + 'nova/*', + ]; +} diff --git a/tests/Inertia/Fixtures/User.php b/tests/Inertia/Fixtures/User.php new file mode 100644 index 000000000..9da8cfc1a --- /dev/null +++ b/tests/Inertia/Fixtures/User.php @@ -0,0 +1,12 @@ + + +Welcome +Welcome + diff --git a/tests/Inertia/HelperTest.php b/tests/Inertia/HelperTest.php new file mode 100644 index 000000000..5ffcf03cc --- /dev/null +++ b/tests/Inertia/HelperTest.php @@ -0,0 +1,32 @@ +assertInstanceOf(ResponseFactory::class, inertia()); + } + + public function testTheHelperFunctionReturnsAResponseInstance(): void + { + $this->assertInstanceOf(Response::class, inertia('User/Edit', ['user' => ['name' => 'Jonathan']])); + } + + public function testTheInstanceIsTheSameAsTheFacadeInstance(): void + { + Inertia::share('key', 'value'); + $this->assertEquals('value', inertia()->getShared('key')); + } +} diff --git a/tests/Inertia/HistoryTest.php b/tests/Inertia/HistoryTest.php new file mode 100644 index 000000000..c3b41aa82 --- /dev/null +++ b/tests/Inertia/HistoryTest.php @@ -0,0 +1,227 @@ +get('/', function () { + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJsonMissing(['encryptHistory' => true]); + $response->assertJsonMissing(['clearHistory' => true]); + } + + public function testTheHistoryCanBeEncrypted(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::encryptHistory(); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'encryptHistory' => true, + ]); + } + + public function testTheHistoryCanBeEncryptedViaMiddleware(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class, EncryptHistoryMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'encryptHistory' => true, + ]); + } + + public function testTheHistoryCanBeEncryptedViaMiddlewareAlias(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class, 'inertia.encrypt'])->get('/', function () { + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'encryptHistory' => true, + ]); + } + + public function testTheHistoryCanBeEncryptedGlobally(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Config::set('inertia.history.encrypt', true); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'encryptHistory' => true, + ]); + } + + public function testTheHistoryCanBeEncryptedGloballyAndOverridden(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Config::set('inertia.history.encrypt', true); + + Inertia::encryptHistory(false); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJsonMissing(['encryptHistory' => true]); + } + + public function testTheHistoryCanBeCleared(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::clearHistory(); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'clearHistory' => true, + ]); + } + + public function testTheHistoryCanBeClearedWhenRedirecting(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::clearHistory(); + + return redirect('/users'); + }); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/users', function () { + return Inertia::render('User/Edit'); + }); + + $this->followingRedirects(); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertContent('
'); + } + + public function testTheFragmentIsNotPreservedByDefault(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJsonMissing([ + 'preserveFragment' => true, + ]); + } + + public function testTheFragmentCanBePreservedViaInertiaFacade(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::preserveFragment(); + + return redirect('/users'); + }); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/users', function () { + return Inertia::render('User/Edit'); + }); + + $this->withoutExceptionHandling()->get('/'); + + $response = $this->withoutExceptionHandling()->get('/users', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'preserveFragment' => true, + ]); + } + + public function testTheFragmentCanBePreservedViaRedirectMacro(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return redirect('/users')->preserveFragment(); /* @phpstan-ignore method.notFound */ + }); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/users', function () { + return Inertia::render('User/Edit'); + }); + + $this->withoutExceptionHandling()->get('/'); + + $response = $this->withoutExceptionHandling()->get('/users', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'preserveFragment' => true, + ]); + } +} diff --git a/tests/Inertia/HttpGatewayTest.php b/tests/Inertia/HttpGatewayTest.php new file mode 100644 index 000000000..db356bcca --- /dev/null +++ b/tests/Inertia/HttpGatewayTest.php @@ -0,0 +1,578 @@ +gateway = app(HttpGateway::class); + $this->renderUrl = $this->gateway->getProductionUrl('/render'); + + Http::preventStrayRequests(); + } + + protected function tearDown(): void + { + $this->removeHotFile(); + + parent::tearDown(); + } + + protected function createHotFile(string $url = 'http://localhost:5173'): void + { + file_put_contents(public_path('hot'), $url); + } + + protected function removeHotFile(): void + { + $hotFile = public_path('hot'); + if (file_exists($hotFile)) { + unlink($hotFile); + } + } + + public function testItReturnsNullWhenSsrIsDisabled(): void + { + config([ + 'inertia.ssr.enabled' => false, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItReturnsNullWhenNoBundleFileIsDetected(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => null, + ]); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItUsesTheConfiguredHttpUrlWhenTheBundleFileIsDetected(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'head' => ['SSR Test', ''], + 'body' => '
SSR Response
', + ])), + ]); + + $this->assertNotNull( + $response = $this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT]) + ); + + $this->assertEquals("SSR Test\n", $response->head); + $this->assertEquals('
SSR Response
', $response->body); + } + + public function testItUsesTheConfiguredHttpUrlWhenBundleFileDetectionIsDisabled(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.ensure_bundle_exists' => false, + 'inertia.ssr.bundle' => null, + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'head' => ['SSR Test', ''], + 'body' => '
SSR Response
', + ])), + ]); + + $this->assertNotNull( + $response = $this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT]) + ); + + $this->assertEquals("SSR Test\n", $response->head); + $this->assertEquals('
SSR Response
', $response->body); + } + + public function testItReturnsNullWhenTheHttpRequestFails(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => Http::response(null, 500), + ]); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItReturnsNullWhenInvalidJsonIsReturned(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => Http::response('invalid json'), + ]); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + /** + * Create a new connection exception for use during stubbing. + * + * This is copied over from Laravel's Http::failedConnection() helper + * method, which is only available in Laravel 11.32.0 and later. + */ + private static function rejectionForFailedConnection(): PromiseInterface + { + return Create::rejectionFor( + new ConnectException('Connection refused', new Request('GET', '/')) + ); + } + + public function testHealthCheckTheSsrServer(): void + { + Http::fake([ + $this->gateway->getProductionUrl('/health') => Http::sequence() + ->push(status: 200) + ->push(status: 500) + ->pushResponse(self::rejectionForFailedConnection()), + ]); + + $this->assertTrue($this->gateway->isHealthy()); + $this->assertFalse($this->gateway->isHealthy()); + $this->assertFalse($this->gateway->isHealthy()); + } + + public function testItUsesViteHotUrlWhenRunningHot(): void + { + config(['inertia.ssr.enabled' => true]); + + $this->createHotFile('http://localhost:5173'); + + Http::fake([ + 'http://localhost:5173/__inertia_ssr' => Http::response(json_encode([ + 'head' => ['Hot SSR'], + 'body' => '
Hot Response
', + ])), + ]); + + $response = $this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertNotNull($response); + $this->assertEquals('Hot SSR', $response->head); + $this->assertEquals('
Hot Response
', $response->body); + } + + public function testItUsesViteHotUrlEvenWhenBundleFileExists(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->createHotFile('http://localhost:5173'); + + Http::fake([ + 'http://localhost:5173/__inertia_ssr' => Http::response(json_encode([ + 'head' => ['Hot SSR'], + 'body' => '
Hot Response
', + ])), + $this->renderUrl => Http::response(json_encode([ + 'head' => ['Production SSR'], + 'body' => '
Production Response
', + ])), + ]); + + $response = $this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertNotNull($response); + $this->assertEquals('Hot SSR', $response->head); + $this->assertEquals('
Hot Response
', $response->body); + } + + public function testItReturnsNullWhenPathIsExcludedFromSsr(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->except(['admin/*']); + + $this->get('/admin/dashboard'); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItDispatchesWhenPathIsNotExcludedFromSsr(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'head' => ['SSR Test'], + 'body' => '
SSR Response
', + ])), + ]); + + $this->gateway->except(['admin/*']); + + $this->get('/users'); + + $this->assertNotNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItReturnsNullWhenFullUrlIsExcludedFromSsr(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->except(['http://localhost/admin/*']); + + $this->get('/admin/dashboard'); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testExceptAcceptsString(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->except('admin/*'); + + $this->get('/admin/dashboard'); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testProductionUrlStripsTrailingSlash(): void + { + config(['inertia.ssr.url' => 'http://127.0.0.1:13714/']); + + $gateway = app(HttpGateway::class); + + $this->assertEquals('http://127.0.0.1:13714/render', $gateway->getProductionUrl('/render')); + } + + public function testExceptCanBeCalledMultipleTimes(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->except('admin/*'); + $this->gateway->except(['nova/*', 'filament/*']); + + $this->get('/nova/resources'); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItDispatchesEventWhenSsrFails(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'error' => 'window is not defined', + 'type' => 'browser-api', + 'hint' => 'Wrap in lifecycle hook', + 'browserApi' => 'window', + ]), 500), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + + Event::assertDispatched(SsrRenderFailed::class, function (SsrRenderFailed $event) { + return $event->error === 'window is not defined' + && $event->type === SsrErrorType::BrowserApi + && $event->hint === 'Wrap in lifecycle hook' + && $event->browserApi === 'window' + && $event->component() === 'Foo/Bar'; + }); + } + + public function testItHandlesConnectionErrorsGracefully(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => self::rejectionForFailedConnection(), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + + Event::assertDispatched(SsrRenderFailed::class, function (SsrRenderFailed $event) { + return $event->type === SsrErrorType::Connection + && str_contains($event->error, 'Connection refused'); + }); + } + + public function testItThrowsExceptionWhenThrowOnErrorIsEnabled(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.throw_on_error' => true, + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'error' => 'window is not defined', + 'type' => 'browser-api', + 'hint' => 'Wrap in lifecycle hook', + 'browserApi' => 'window', + 'sourceLocation' => 'resources/js/Pages/Dashboard.vue:10:5', + ]), 500), + ]); + + $this->expectException(SsrException::class); + $this->expectExceptionMessage('SSR render failed for component [Foo/Bar]: window is not defined'); + + $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + } + + public function testSsrExceptionContainsErrorDetails(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.throw_on_error' => true, + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'error' => 'window is not defined', + 'type' => 'browser-api', + 'hint' => 'Wrap in lifecycle hook', + 'browserApi' => 'window', + 'sourceLocation' => 'resources/js/Pages/Dashboard.vue:10:5', + ]), 500), + ]); + + try { + $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + $this->fail('Expected SsrException was not thrown'); + } catch (SsrException $e) { + $this->assertEquals('Foo/Bar', $e->component()); + $this->assertSame(SsrErrorType::BrowserApi, $e->type()); + $this->assertEquals('Wrap in lifecycle hook', $e->hint()); + $this->assertEquals('resources/js/Pages/Dashboard.vue:10:5', $e->sourceLocation()); + $this->assertStringContainsString('at resources/js/Pages/Dashboard.vue:10:5', $e->getMessage()); + } + } + + public function testItThrowsExceptionOnConnectionErrorWhenThrowOnErrorIsEnabled(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.throw_on_error' => true, + ]); + + Http::fake([ + $this->renderUrl => self::rejectionForFailedConnection(), + ]); + + $this->expectException(SsrException::class); + $this->expectExceptionMessage('Connection refused'); + + $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + } + + public function testItReturnsNullWhenDisabledWithBoolean(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->disable(true); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItReturnsNullWhenDisabledWithClosure(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->disable(fn () => true); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testDisableWhenTakesPrecedenceOverConfig(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->disable(false); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'head' => ['SSR Test'], + 'body' => '
SSR Response
', + ])), + ]); + + $this->assertNotNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItDoesNotThrowExceptionWhenThrowOnErrorIsDisabled(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.throw_on_error' => false, + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'error' => 'window is not defined', + 'type' => 'browser-api', + ]), 500), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + } + + public function testCircuitBreakerSkipsSsrAfterFailure(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.backoff' => 5.0, + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'error' => 'Server down', + 'type' => 'connection', + ]), 500), + ]); + + // First dispatch fails — triggers circuit breaker + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + + // Second dispatch should be skipped (circuit breaker active) + // Even with a successful fake, gateway won't attempt the request + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'head' => ['SSR'], + 'body' => '
SSR
', + ])), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + } + + public function testCircuitBreakerResetsAfterFlushState(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.backoff' => 5.0, + ]); + + Http::fake([ + $this->renderUrl => Http::sequence() + ->push(json_encode(['error' => 'Server down', 'type' => 'connection']), 500) + ->push(json_encode(['head' => ['SSR'], 'body' => '
SSR
'])), + ]); + + // First dispatch fails — triggers circuit breaker + $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + + // Flush resets the circuit breaker + HttpGateway::flushState(); + + // Second dispatch should succeed — circuit breaker is reset + $response = $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + $this->assertNotNull($response); + $this->assertSame('
SSR
', $response->body); + } + + public function testItHandlesScalarJsonErrorResponseGracefully(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => Http::response('"Internal Server Error"', 500), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + + Event::assertDispatched(SsrRenderFailed::class, function (SsrRenderFailed $event) { + return $event->error === 'Unknown SSR error'; + }); + } +} diff --git a/tests/Inertia/InertiaServiceProviderTest.php b/tests/Inertia/InertiaServiceProviderTest.php new file mode 100644 index 000000000..caf26dc45 --- /dev/null +++ b/tests/Inertia/InertiaServiceProviderTest.php @@ -0,0 +1,84 @@ +assertArrayHasKey('inertia', Blade::getCustomDirectives()); + } + + public function testRequestMacroIsRegistered(): void + { + $request = Request::create('/user/123', 'GET'); + + $this->assertFalse($request->inertia()); + + $request->headers->add(['X-Inertia' => 'true']); + + $this->assertTrue($request->inertia()); + } + + public function testRouteMacroIsRegistered(): void + { + $route = Route::inertia('/', 'User/Edit', ['user' => ['name' => 'Jonathan']]); + $routes = Route::getRoutes(); + + $this->assertNotEmpty($routes->getRoutes()); + + $inertiaRoute = collect($routes->getRoutes())->first(fn ($route) => $route->uri === '/'); + + $this->assertEquals($route, $inertiaRoute); + $this->assertEquals(['GET', 'HEAD'], $inertiaRoute->methods); + $this->assertEquals('/', $inertiaRoute->uri); + $this->assertEquals(['uses' => '\Hypervel\Inertia\Controller@__invoke', 'controller' => '\Hypervel\Inertia\Controller'], $inertiaRoute->action); + $this->assertEquals(['component' => 'User/Edit', 'props' => ['user' => ['name' => 'Jonathan']]], $inertiaRoute->defaults); + } + + public function testEnsureGetOnRedirectMiddlewareIsRegisteredGlobally(): void + { + /** @var Kernel $kernel */ + $kernel = $this->app->make(HttpKernelContract::class); + + $this->assertTrue($kernel->hasMiddleware(EnsureGetOnRedirect::class)); + } + + public function testRedirectResponseFromRateLimiterIsConvertedTo303(): void + { + RateLimiter::for('api', fn () => Limit::perMinute(1)->response(fn () => back())); + + // Needed for the web middleware + config(['app.key' => 'base64:' . base64_encode(random_bytes(32))]); + + Route::middleware(['web', ExampleMiddleware::class, 'throttle:api']) + ->delete('/foo', fn () => 'ok'); + + $this + ->from('/bar') + ->delete('/foo', [], ['X-Inertia' => 'true']) + ->assertOk(); + + $this + ->from('/bar') + ->delete('/foo', [], ['X-Inertia' => 'true']) + ->assertRedirect('/bar') + ->assertStatus(303); + } +} diff --git a/tests/Inertia/InteractsWithUserModels.php b/tests/Inertia/InteractsWithUserModels.php new file mode 100644 index 000000000..bce9bc0d8 --- /dev/null +++ b/tests/Inertia/InteractsWithUserModels.php @@ -0,0 +1,25 @@ +set('database.default', 'sqlite'); + config()->set('database.connections.sqlite.database', ':memory:'); + + DB::statement('DROP TABLE IF EXISTS users'); + DB::statement('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT)'); + DB::table('users')->insert(array_fill(0, 40, ['id' => null])); + } + + protected function tearDownInteractsWithUserModels(): void + { + DB::statement('DROP TABLE users'); + } +} diff --git a/tests/Inertia/MergePropTest.php b/tests/Inertia/MergePropTest.php new file mode 100644 index 000000000..9ff3bf168 --- /dev/null +++ b/tests/Inertia/MergePropTest.php @@ -0,0 +1,186 @@ +assertSame('A merge prop value', $mergeProp()); + } + + public function testCanInvokeWithANonCallback(): void + { + $mergeProp = new MergeProp(['key' => 'value']); + + $this->assertSame(['key' => 'value'], $mergeProp()); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $mergeProp = new MergeProp('date'); + + $this->assertSame('date', $mergeProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $mergeProp = new MergeProp(function (Request $request) { + return $request; + }); + + $this->assertInstanceOf(Request::class, $mergeProp()); + } + + public function testAppendsByDefault(): void + { + $mergeProp = new MergeProp([]); + + $this->assertTrue($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function testPrepends(): void + { + $mergeProp = (new MergeProp([]))->prepend(); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertTrue($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function testAppendsWithNestedMergePaths(): void + { + $mergeProp = (new MergeProp([]))->append('data'); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data'], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function testAppendsWithNestedMergePathsAndMatchOn(): void + { + $mergeProp = (new MergeProp([]))->append('data', 'id'); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data'], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame(['data.id'], $mergeProp->matchesOn()); + } + + public function testPrependsWithNestedMergePaths(): void + { + $mergeProp = (new MergeProp([]))->prepend('data'); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame(['data'], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function testPrependsWithNestedMergePathsAndMatchOn(): void + { + $mergeProp = (new MergeProp([]))->prepend('data', 'id'); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame(['data'], $mergeProp->prependsAtPaths()); + $this->assertSame(['data.id'], $mergeProp->matchesOn()); + } + + public function testAppendWithNestedMergePathsAsArray(): void + { + $mergeProp = (new MergeProp([]))->append(['data', 'items']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data', 'items'], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function testAppendWithNestedMergePathsAndMatchOnAsArray(): void + { + $mergeProp = (new MergeProp([]))->append(['data' => 'id', 'items' => 'uid']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data', 'items'], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame(['data.id', 'items.uid'], $mergeProp->matchesOn()); + } + + public function testPrependWithNestedMergePathsAsArray(): void + { + $mergeProp = (new MergeProp([]))->prepend(['data', 'items']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame(['data', 'items'], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function testPrependWithNestedMergePathsAndMatchOnAsArray(): void + { + $mergeProp = (new MergeProp([]))->prepend(['data' => 'id', 'items' => 'uid']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame(['data', 'items'], $mergeProp->prependsAtPaths()); + $this->assertSame(['data.id', 'items.uid'], $mergeProp->matchesOn()); + } + + public function testMixOfAppendAndPrependWithNestedMergePathsAndMatchOnAsArray(): void + { + $mergeProp = (new MergeProp([])) + ->append('data') + ->append('users', 'id') + ->append(['items' => 'uid', 'posts']) + ->prepend('categories') + ->prepend('companies', 'id') + ->prepend(['tags' => 'name', 'comments']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data', 'users', 'items', 'posts'], $mergeProp->appendsAtPaths()); + $this->assertSame(['categories', 'companies', 'tags', 'comments'], $mergeProp->prependsAtPaths()); + $this->assertSame(['users.id', 'items.uid', 'companies.id', 'tags.name'], $mergeProp->matchesOn()); + } + + public function testIsOnceable(): void + { + $mergeProp = (new MergeProp(fn () => [])) + ->once() + ->as('custom-key') + ->until(60); + + $this->assertTrue($mergeProp->shouldResolveOnce()); + $this->assertSame('custom-key', $mergeProp->getKey()); + $this->assertNotNull($mergeProp->expiresAt()); + } +} diff --git a/tests/Inertia/MiddlewareTest.php b/tests/Inertia/MiddlewareTest.php new file mode 100644 index 000000000..560e0e734 --- /dev/null +++ b/tests/Inertia/MiddlewareTest.php @@ -0,0 +1,586 @@ +cleanDirectory(public_path()); + } + + public function testNoResponseValueByDefaultMeansAutomaticallyRedirectingBackForInertiaRequests(): void + { + $fooCalled = false; + Route::middleware(Middleware::class)->put('/', function () use (&$fooCalled) { + $fooCalled = true; + }); + + $response = $this + ->from('/foo') + ->put('/', [], [ + 'X-Inertia' => 'true', + 'Content-Type' => 'application/json', + ]); + + $response->assertRedirect('/foo'); + $response->assertStatus(303); + $this->assertTrue($fooCalled); + } + + public function testNoResponseValueCanBeCustomizedByOverridingTheMiddlewareMethod(): void + { + Route::middleware(ExampleMiddleware::class)->get('/', function () { + // Do nothing.. + }); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('An empty Inertia response was returned.'); + + $this + ->withoutExceptionHandling() + ->from('/foo') + ->get('/', [ + 'X-Inertia' => 'true', + 'Content-Type' => 'application/json', + ]); + } + + public function testNoResponseMeansNoResponseForNonInertiaRequests(): void + { + $fooCalled = false; + Route::middleware(Middleware::class)->put('/', function () use (&$fooCalled) { + $fooCalled = true; + }); + + $response = $this + ->from('/foo') + ->put('/', [], [ + 'Content-Type' => 'application/json', + ]); + + $response->assertNoContent(200); + $this->assertTrue($fooCalled); + } + + public function testTheVersionIsOptional(): void + { + $this->prepareMockEndpoint(); + + $response = $this->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson(['component' => 'User/Edit']); + } + + public function testTheVersionCanBeANumber(): void + { + $this->prepareMockEndpoint($version = 1597347897973); + + $response = $this->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => $version, + ]); + + $response->assertSuccessful(); + $response->assertJson(['component' => 'User/Edit']); + } + + public function testTheVersionCanBeAString(): void + { + $this->prepareMockEndpoint($version = 'foo-version'); + + $response = $this->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => $version, + ]); + + $response->assertSuccessful(); + $response->assertJson(['component' => 'User/Edit']); + } + + public function testItWillInstructInertiaToReloadOnAVersionMismatch(): void + { + $this->prepareMockEndpoint('1234'); + + $response = $this->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => '4321', + ]); + + $response->assertStatus(409); + $response->assertHeader('X-Inertia-Location', $this->baseUrl); + self::assertEmpty($response->getContent()); + } + + public function testTheUrlCanBeResolvedWithACustomResolver(): void + { + $this->prepareMockEndpoint(middleware: new CustomUrlResolverMiddleware); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'url' => '/my-custom-url', + ]); + } + + public function testValidationErrorsAreRegisteredAsOfDefault(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $this->assertInstanceOf(AlwaysProp::class, Inertia::getShared('errors')); + }); + + $this->withoutExceptionHandling()->get('/'); + } + + public function testValidationErrorsCanBeEmpty(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $errors = Inertia::getShared('errors')(); + + $this->assertIsObject($errors); + $this->assertEmpty(get_object_vars($errors)); + }); + + $this->withoutExceptionHandling()->get('/'); + } + + public function testValidationErrorsAreMappedToStringsByDefault(): void + { + Session::put('errors', (new ViewErrorBag)->put('default', new MessageBag([ + 'name' => ['The name field is required.'], + 'email' => ['Not a valid email address.', 'Another email error.'], + ]))); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $errors = Inertia::getShared('errors')(); + + $this->assertIsObject($errors); + $this->assertSame('The name field is required.', $errors->name); + $this->assertSame('Not a valid email address.', $errors->email); + }); + + $this->withoutExceptionHandling()->get('/'); + } + + public function testValidationErrorsCanRemainMultiplePerField(): void + { + Session::put('errors', (new ViewErrorBag)->put('default', new MessageBag([ + 'name' => ['The name field is required.'], + 'email' => ['Not a valid email address.', 'Another email error.'], + ]))); + + Route::middleware([StartSession::class, WithAllErrorsMiddleware::class])->get('/', function () { + $errors = Inertia::getShared('errors')(); + + $this->assertIsObject($errors); + $this->assertSame(['The name field is required.'], $errors->name); + $this->assertSame( + ['Not a valid email address.', 'Another email error.'], + $errors->email + ); + }); + + $this->withoutExceptionHandling()->get('/'); + } + + public function testValidationErrorsWithNamedErrorBagsAreScoped(): void + { + Session::put('errors', (new ViewErrorBag)->put('example', new MessageBag([ + 'name' => 'The name field is required.', + 'email' => 'Not a valid email address.', + ]))); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $errors = Inertia::getShared('errors')(); + + $this->assertIsObject($errors); + $this->assertSame('The name field is required.', $errors->example->name); + $this->assertSame('Not a valid email address.', $errors->example->email); + }); + + $this->withoutExceptionHandling()->get('/'); + } + + public function testDefaultValidationErrorsCanBeOverwritten(): void + { + Session::put('errors', (new ViewErrorBag)->put('example', new MessageBag([ + 'name' => 'The name field is required.', + 'email' => 'Not a valid email address.', + ]))); + + $this->prepareMockEndpoint(null, ['errors' => 'foo']); + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertJson([ + 'props' => [ + 'errors' => 'foo', + ], + ]); + } + + public function testValidationErrorsAreScopedToErrorBagHeader(): void + { + Session::put('errors', (new ViewErrorBag)->put('default', new MessageBag([ + 'name' => 'The name field is required.', + 'email' => 'Not a valid email address.', + ]))); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $errors = Inertia::getShared('errors')(); + + $this->assertIsObject($errors); + $this->assertSame('The name field is required.', $errors->example->name); + $this->assertSame('Not a valid email address.', $errors->example->email); + }); + + $this->withoutExceptionHandling()->get('/', ['X-Inertia-Error-Bag' => 'example']); + } + + public function testMiddlewareCanChangeTheRootViewViaAProperty(): void + { + $this->prepareMockEndpoint(null, [], new class extends Middleware { + protected string $rootView = 'welcome'; + }); + + $response = $this->get('/'); + $response->assertOk(); + $response->assertViewIs('welcome'); + } + + public function testMiddlewareCanChangeTheRootViewByOverridingTheRootviewMethod(): void + { + $this->prepareMockEndpoint(null, [], new class extends Middleware { + public function rootView(Request $request): string + { + return 'welcome'; + } + }); + + $response = $this->get('/'); + $response->assertOk(); + $response->assertViewIs('welcome'); + } + + public function testDetermineTheVersionByAHashOfTheAssetUrl(): void + { + config(['app.asset_url' => $url = 'https://example.com/assets']); + + $this->prepareMockEndpoint(middleware: new Middleware); + + $response = $this->get('/'); + $response->assertOk(); + $response->assertViewHas('page.version', hash('xxh128', $url)); + } + + public function testDetermineTheVersionByAHashOfTheViteManifest(): void + { + $filesystem = new Filesystem; + $filesystem->ensureDirectoryExists(public_path('build')); + $filesystem->put( + public_path('build/manifest.json'), + $contents = json_encode(['vite' => true]) + ); + + $this->prepareMockEndpoint(middleware: new Middleware); + + $response = $this->get('/'); + $response->assertOk(); + $response->assertViewHas('page.version', hash('xxh128', $contents)); + } + + public function testDetermineTheVersionByAHashOfTheMixManifest(): void + { + $filesystem = new Filesystem; + $filesystem->ensureDirectoryExists(public_path()); + $filesystem->put( + public_path('mix-manifest.json'), + $contents = json_encode(['mix' => true]) + ); + + $this->prepareMockEndpoint(middleware: new Middleware); + + $response = $this->get('/'); + $response->assertOk(); + $response->assertViewHas('page.version', hash('xxh128', $contents)); + } + + public function testMiddlewareShareOnce(): void + { + $middleware = new class extends Middleware { + public function shareOnce(Request $request): array + { + return [ + 'permissions' => fn () => ['admin' => true], + 'settings' => Inertia::once(fn () => ['theme' => 'dark']) + ->as('app-settings') + ->until(60), + ]; + } + }; + + Route::middleware(StartSession::class)->get('/', function (Request $request) use ($middleware) { + return $middleware->handle($request, function ($request) { + return Inertia::render('User/Edit')->toResponse($request); + }); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'props' => [ + 'permissions' => ['admin' => true], + 'settings' => ['theme' => 'dark'], + ], + 'onceProps' => [ + 'permissions' => ['prop' => 'permissions', 'expiresAt' => null], + 'app-settings' => ['prop' => 'settings'], + ], + ]); + $this->assertNotNull($response->json('onceProps.app-settings.expiresAt')); + } + + public function testMiddlewareShareAndShareOnceAreMerged(): void + { + $middleware = new class extends Middleware { + public function share(Request $request): array + { + return array_merge(parent::share($request), [ + 'flash' => fn () => ['message' => 'Hello'], + ]); + } + + public function shareOnce(Request $request): array + { + return array_merge(parent::shareOnce($request), [ + 'permissions' => fn () => ['admin' => true], + ]); + } + }; + + Route::middleware(StartSession::class)->get('/', function (Request $request) use ($middleware) { + return $middleware->handle($request, function ($request) { + return Inertia::render('User/Edit')->toResponse($request); + }); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'props' => [ + 'flash' => ['message' => 'Hello'], + 'permissions' => ['admin' => true], + ], + 'onceProps' => [ + 'permissions' => ['prop' => 'permissions', 'expiresAt' => null], + ], + ]); + } + + public function testFlashDataIsPreservedOnNonInertiaRedirect(): void + { + Route::middleware([StartSession::class, Middleware::class])->get('/action', function () { + Inertia::flash('message', 'Success!'); + + return redirect('/dashboard'); + }); + + Route::middleware([StartSession::class, Middleware::class])->get('/dashboard', function () { + return Inertia::render('Dashboard'); + }); + + $response = $this->get('/action'); + $response->assertRedirect('/dashboard'); + + $response = $this->get('/dashboard', ['X-Inertia' => 'true']); + $response->assertJson([ + 'flash' => ['message' => 'Success!'], + ]); + } + + public function testRedirectWithHashFragmentReturns409ForInertiaRequests(): void + { + Route::middleware([StartSession::class, Middleware::class])->get('/action', function () { + return redirect('/article#section'); + }); + + $response = $this->get('/action', [ + 'X-Inertia' => 'true', + ]); + + $response->assertStatus(409); + $response->assertHeader('X-Inertia-Redirect', $this->baseUrl . '/article#section'); + self::assertEmpty($response->getContent()); + } + + public function testRedirectWithoutHashFragmentIsNotIntercepted(): void + { + Route::middleware([StartSession::class, Middleware::class])->post('/action', function () { + return redirect('/article'); + }); + + $response = $this->post('/action', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertRedirect('/article'); + $response->assertStatus(302); + } + + public function testRedirectWithHashFragmentIsNotInterceptedForNonInertiaRequests(): void + { + Route::middleware([StartSession::class, Middleware::class])->get('/action', function () { + return redirect('/article#section'); + }); + + $response = $this->get('/action'); + + $response->assertRedirect($this->baseUrl . '/article#section'); + $response->assertStatus(302); + } + + public function testPostRedirectWithHashFragmentReturns409ForInertiaRequests(): void + { + Route::middleware([StartSession::class, Middleware::class])->post('/action', function () { + return redirect('/article#section'); + }); + + $response = $this->post('/action', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertStatus(409); + $response->assertHeader('X-Inertia-Redirect', $this->baseUrl . '/article#section'); + } + + public function testRedirectWithHashFragmentIsNotInterceptedForPrefetchRequests(): void + { + Route::middleware([StartSession::class, Middleware::class])->get('/action', function () { + return redirect('/article#section'); + }); + + $response = $this->get('/action', [ + 'X-Inertia' => 'true', + 'Purpose' => 'prefetch', + ]); + + $response->assertRedirect($this->baseUrl . '/article#section'); + } + + public function testMiddlewareRegistersSsrExceptPaths(): void + { + $middleware = new SsrExceptMiddleware; + + Route::middleware(StartSession::class)->get('/admin/dashboard', function (Request $request) use ($middleware) { + return $middleware->handle($request, function ($request) { + return Inertia::render('Admin/Dashboard')->toResponse($request); + }); + }); + + $this->get('/admin/dashboard'); + + $this->assertContains('admin/*', app(HttpGateway::class)->getExcludedPaths()); + $this->assertContains('nova/*', app(HttpGateway::class)->getExcludedPaths()); + } + + public function testVersionIsCachedForWorkerLifetime(): void + { + $filesystem = new Filesystem; + $filesystem->ensureDirectoryExists(public_path('build')); + $filesystem->put( + public_path('build/manifest.json'), + $originalContents = json_encode(['v1' => true]) + ); + + $middleware = new Middleware; + $request = Request::create('/'); + + $firstVersion = $middleware->version($request); + $this->assertSame(hash('xxh128', $originalContents), $firstVersion); + + // Change the manifest — cached version should still be returned + $filesystem->put( + public_path('build/manifest.json'), + json_encode(['v2' => true]) + ); + + $secondVersion = $middleware->version($request); + $this->assertSame($firstVersion, $secondVersion); + } + + public function testVersionCacheResetsAfterFlushState(): void + { + $filesystem = new Filesystem; + $filesystem->ensureDirectoryExists(public_path('build')); + $filesystem->put( + public_path('build/manifest.json'), + json_encode(['v1' => true]) + ); + + $middleware = new Middleware; + $request = Request::create('/'); + + $firstVersion = $middleware->version($request); + + Middleware::flushState(); + + $filesystem->put( + public_path('build/manifest.json'), + $newContents = json_encode(['v2' => true]) + ); + + $secondVersion = $middleware->version($request); + $this->assertSame(hash('xxh128', $newContents), $secondVersion); + $this->assertNotSame($firstVersion, $secondVersion); + } + + /** + * @param array $shared + */ + private function prepareMockEndpoint(int|string|null $version = null, array $shared = [], ?Middleware $middleware = null): RouteInstance + { + if (is_null($middleware)) { + $middleware = new ExampleMiddleware($version, $shared); + } + + return Route::middleware(StartSession::class)->get('/', function (Request $request) use ($middleware) { + return $middleware->handle($request, function ($request) { + return Inertia::render('User/Edit', ['user' => ['name' => 'Jonathan']])->toResponse($request); + }); + }); + } +} diff --git a/tests/Inertia/OncePropTest.php b/tests/Inertia/OncePropTest.php new file mode 100644 index 000000000..4c463b433 --- /dev/null +++ b/tests/Inertia/OncePropTest.php @@ -0,0 +1,89 @@ +assertSame('A once prop value', $onceProp()); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $onceProp = new OnceProp('date'); + + $this->assertSame('date', $onceProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $onceProp = new OnceProp(function (Request $request) { + return $request; + }); + + $this->assertInstanceOf(Request::class, $onceProp()); + } + + public function testCanSetCustomKey(): void + { + $onceProp = new OnceProp(fn () => 'value'); + + $result = $onceProp->as('custom-key'); + $this->assertSame($onceProp, $result); + $this->assertSame('custom-key', $onceProp->getKey()); + + $onceProp->as(TestBackedEnum::Foo); + $this->assertSame('foo-value', $onceProp->getKey()); + + $onceProp->as(TestUnitEnum::Baz); + $this->assertSame('Baz', $onceProp->getKey()); + } + + public function testShouldNotBeRefreshedByDefault(): void + { + $onceProp = new OnceProp(fn () => 'value'); + + $this->assertFalse($onceProp->shouldBeRefreshed()); + } + + public function testCanForcefullyRefresh(): void + { + $onceProp = new OnceProp(fn () => 'value'); + $onceProp->fresh(); + + $this->assertTrue($onceProp->shouldBeRefreshed()); + } + + public function testCanDisableForcefulRefresh(): void + { + $onceProp = new OnceProp(fn () => 'value'); + $onceProp->fresh(); + $onceProp->fresh(false); + + $this->assertFalse($onceProp->shouldBeRefreshed()); + } +} diff --git a/tests/Inertia/OptionalPropTest.php b/tests/Inertia/OptionalPropTest.php new file mode 100644 index 000000000..b47fa4d39 --- /dev/null +++ b/tests/Inertia/OptionalPropTest.php @@ -0,0 +1,52 @@ +assertSame('An optional value', $optionalProp()); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $optionalProp = new OptionalProp('date'); + + $this->assertSame('date', $optionalProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $optionalProp = new OptionalProp(function (Request $request) { + return $request; + }); + + $this->assertInstanceOf(Request::class, $optionalProp()); + } + + public function testIsOnceable(): void + { + $optionalProp = (new OptionalProp(fn () => 'value')) + ->once() + ->as('custom-key') + ->until(60); + + $this->assertTrue($optionalProp->shouldResolveOnce()); + $this->assertSame('custom-key', $optionalProp->getKey()); + $this->assertNotNull($optionalProp->expiresAt()); + } +} diff --git a/tests/Inertia/PropsResolverTest.php b/tests/Inertia/PropsResolverTest.php new file mode 100644 index 000000000..890d49e6f --- /dev/null +++ b/tests/Inertia/PropsResolverTest.php @@ -0,0 +1,1080 @@ +makePage(Request::create('/'), [ + 'auth' => fn () => ['user' => 'Jonathan'], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + } + + public function testNestedClosureInsideArrayIsResolved(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => fn () => 'Jonathan', + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + } + + public function testNestedProvidesInertiaPropertiesIsResolved(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return ['user' => 'Jonathan', 'role' => 'admin']; + } + }, + 'team' => 'Inertia', + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertSame('admin', $page['props']['auth']['role']); + $this->assertSame('Inertia', $page['props']['auth']['team']); + } + + public function testNestedProvidesInertiaPropertiesWithPropTypes(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'user' => 'Jonathan', + 'permissions' => Inertia::optional(fn () => ['manage-users']), + ]; + } + }, + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('permissions', $page['props']['auth']); + } + + public function testNestedProvidesInertiaPropertiesWithPropTypesOnPartialRequest(): void + { + $page = $this->makePage( + $this->makePartialRequest('auth.permissions'), + [ + 'auth' => [ + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'user' => 'Jonathan', + 'permissions' => Inertia::optional(fn () => ['manage-users']), + ]; + } + }, + ], + ], + ); + + $this->assertArrayNotHasKey('user', $page['props']['auth']); + $this->assertSame(['manage-users'], $page['props']['auth']['permissions']); + } + + public function testNestedAlwaysPropIsResolved(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => Inertia::always(fn () => 'Jonathan'), + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + } + + public function testNestedMergePropIsResolved(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ]); + + $this->assertSame([['id' => 1]], $page['props']['feed']['posts']); + } + + public function testNestedOncePropIsResolvedOnInitialLoad(): void + { + $page = $this->makePage(Request::create('/'), [ + 'config' => [ + 'locale' => Inertia::once(fn () => 'en'), + ], + ]); + + $this->assertSame('en', $page['props']['config']['locale']); + } + + public function testNestedOptionalPropIsExcludedFromInitialLoad(): void + { + $resolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => 'Jonathan', + 'permissions' => Inertia::optional(function () use (&$resolved) { + $resolved = true; + + return ['admin']; + }), + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('permissions', $page['props']['auth']); + $this->assertFalse($resolved, 'OptionalProp closure should not be resolved on initial load'); + } + + public function testNestedDeferPropIsExcludedFromInitialLoad(): void + { + $resolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => 'Jonathan', + 'notifications' => Inertia::defer(function () use (&$resolved) { + $resolved = true; + + return []; + }), + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('notifications', $page['props']['auth']); + $this->assertFalse($resolved, 'DeferProp closure should not be resolved on initial load'); + } + + public function testExcludedPropsAreNotResolvedOnInitialLoad(): void + { + $optionalResolved = false; + $deferResolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'name' => 'Jonathan', + 'permissions' => Inertia::optional(function () use (&$optionalResolved) { + $optionalResolved = true; + + return ['admin']; + }), + 'notifications' => Inertia::defer(function () use (&$deferResolved) { + $deferResolved = true; + + return ['You have a new follower']; + }), + ]); + + $this->assertSame('Jonathan', $page['props']['name']); + $this->assertArrayNotHasKey('permissions', $page['props']); + $this->assertArrayNotHasKey('notifications', $page['props']); + $this->assertFalse($optionalResolved, 'OptionalProp closure should not be resolved on initial load'); + $this->assertFalse($deferResolved, 'DeferProp closure should not be resolved on initial load'); + } + + public function testClosureReturningOptionalPropIsExcludedFromInitialLoad(): void + { + $resolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'auth' => fn () => [ + 'user' => 'Jonathan', + 'permissions' => Inertia::optional(function () use (&$resolved) { + $resolved = true; + + return ['admin']; + }), + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('permissions', $page['props']['auth']); + $this->assertFalse($resolved, 'OptionalProp closure should not be resolved on initial load'); // @phpstan-ignore method.impossibleType + } + + public function testClosureReturningDeferPropIsExcludedFromInitialLoad(): void + { + $resolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'auth' => fn () => [ + 'user' => 'Jonathan', + 'notifications' => Inertia::defer(function () use (&$resolved) { + $resolved = true; + + return []; + }), + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('notifications', $page['props']['auth']); + $this->assertFalse($resolved, 'DeferProp closure should not be resolved on initial load'); // @phpstan-ignore method.impossibleType + } + + public function testClosureReturningMergePropResolvesWithMetadata(): void + { + $page = $this->makePage(Request::create('/'), [ + 'posts' => fn () => new MergeProp([['id' => 1]]), + ]); + + $this->assertSame([['id' => 1]], $page['props']['posts']); + $this->assertSame(['posts'], $page['mergeProps']); + } + + public function testClosureReturningOncePropResolvesWithMetadata(): void + { + $page = $this->makePage(Request::create('/'), [ + 'locale' => fn () => Inertia::once(fn () => 'en'), + ]); + + $this->assertSame('en', $page['props']['locale']); + $this->assertSame(['locale' => ['prop' => 'locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testClosureReturningDeferPropCollectsDeferredAndMergeMetadata(): void + { + $page = $this->makePage(Request::create('/'), [ + 'posts' => fn () => Inertia::defer(fn () => [['id' => 1]])->merge(), + ]); + + $this->assertArrayNotHasKey('posts', $page['props']); + $this->assertSame(['default' => ['posts']], $page['deferredProps']); + $this->assertSame(['posts'], $page['mergeProps']); + } + + public function testNestedOptionalPropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('auth.permissions'), [ + 'auth' => [ + 'user' => 'Jonathan', + 'permissions' => Inertia::optional(fn () => ['admin']), + ], + ]); + + $this->assertSame(['admin'], $page['props']['auth']['permissions']); + } + + public function testNestedDeferPropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('auth.notifications'), [ + 'auth' => [ + 'user' => 'Jonathan', + 'notifications' => Inertia::defer(fn () => ['new message']), + ], + ]); + + $this->assertSame(['new message'], $page['props']['auth']['notifications']); + } + + public function testNestedAlwaysPropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('auth.user'), [ + 'auth' => [ + 'user' => 'Jonathan', + 'errors' => Inertia::always(fn () => ['name' => 'required']), + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertSame(['name' => 'required'], $page['props']['auth']['errors']); + } + + public function testTopLevelAlwaysPropIsIncludedWhenNotRequested(): void + { + $page = $this->makePage($this->makePartialRequest('other'), [ + 'other' => 'value', + 'errors' => Inertia::always(fn () => ['name' => 'required']), + ]); + + $this->assertSame('value', $page['props']['other']); + $this->assertSame(['name' => 'required'], $page['props']['errors']); + } + + public function testNestedMergePropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('feed.posts'), [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ]); + + $this->assertSame([['id' => 1]], $page['props']['feed']['posts']); + } + + public function testNestedPropIsExcludedViaExceptHeader(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'TestComponent']); + $request->headers->add(['X-Inertia-Partial-Data' => 'auth']); + $request->headers->add(['X-Inertia-Partial-Except' => 'auth.token']); + + $page = $this->makePage($request, [ + 'auth' => [ + 'user' => 'Jonathan', + 'token' => 'secret', + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('token', $page['props']['auth']); + } + + public function testPartialRequestForParentResolvesAllNestedPropTypes(): void + { + $page = $this->makePage($this->makePartialRequest('dashboard'), [ + 'dashboard' => [ + 'stats' => 'visible', + 'feed' => new MergeProp([['id' => 1]]), + 'notifications' => Inertia::defer(fn () => ['msg']), + 'settings' => Inertia::optional(fn () => ['theme' => 'dark']), + 'locale' => Inertia::once(fn () => 'en'), + ], + ]); + + $this->assertSame('visible', $page['props']['dashboard']['stats']); + $this->assertSame([['id' => 1]], $page['props']['dashboard']['feed']); + $this->assertSame(['msg'], $page['props']['dashboard']['notifications']); + $this->assertSame(['theme' => 'dark'], $page['props']['dashboard']['settings']); + $this->assertSame('en', $page['props']['dashboard']['locale']); + $this->assertSame(['dashboard.feed'], $page['mergeProps']); + $this->assertSame(['dashboard.locale' => ['prop' => 'dashboard.locale', 'expiresAt' => null]], $page['onceProps']); + $this->assertArrayNotHasKey('deferredProps', $page); + } + + public function testNestedDeferPropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => 'Jonathan', + 'notifications' => Inertia::defer(fn () => []), + ], + ]); + + $this->assertSame(['default' => ['auth.notifications']], $page['deferredProps']); + } + + public function testNestedDeferPropMetadataPreservesGroup(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'notifications' => Inertia::defer(fn () => [], 'sidebar'), + 'messages' => Inertia::defer(fn () => [], 'sidebar'), + ], + ]); + + $this->assertSame(['sidebar' => ['auth.notifications', 'auth.messages']], $page['deferredProps']); + } + + public function testClosureReturningDeferPropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => fn () => [ + 'user' => 'Jonathan', + 'notifications' => Inertia::defer(fn () => [], 'alerts'), + ], + ]); + + $this->assertSame(['alerts' => ['auth.notifications']], $page['deferredProps']); + } + + public function testNestedMergePropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ]); + + $this->assertSame(['feed.posts'], $page['mergeProps']); + } + + public function testNestedPrependMergePropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => (new MergeProp([['id' => 1]]))->prepend(), + ], + ]); + + $this->assertSame(['feed.posts'], $page['prependProps']); + } + + public function testNestedDeepMergePropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'settings' => [ + 'preferences' => (new MergeProp(['theme' => 'dark']))->deepMerge(), + ], + ]); + + $this->assertSame(['settings.preferences'], $page['deepMergeProps']); + } + + public function testNestedMergePropWithNestedPathMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => (new MergeProp(['data' => [['id' => 1]]]))->append('data'), + ], + ]); + + $this->assertSame(['feed.posts.data'], $page['mergeProps']); + } + + public function testNestedMergePropWithMatchOnMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => (new MergeProp([['id' => 1]]))->matchOn('id')->deepMerge(), + ], + ]); + + $this->assertSame(['feed.posts'], $page['deepMergeProps']); + $this->assertSame(['feed.posts.id'], $page['matchPropsOn']); + } + + public function testNestedDeferWithMergeMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => Inertia::defer(fn () => [['id' => 1]])->merge(), + ], + ]); + + $this->assertSame(['default' => ['feed.posts']], $page['deferredProps']); + $this->assertSame(['feed.posts'], $page['mergeProps']); + } + + public function testNestedMergeMetadataIsCollectedOnExactPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('feed.posts'), [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ]); + + $this->assertSame([['id' => 1]], $page['props']['feed']['posts']); + $this->assertSame(['feed.posts'], $page['mergeProps']); + } + + public function testNestedMergeMetadataIsCollectedWhenParentIsRequested(): void + { + $page = $this->makePage($this->makePartialRequest('feed'), [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ]); + + $this->assertSame([['id' => 1]], $page['props']['feed']['posts']); + $this->assertSame(['feed.posts'], $page['mergeProps']); + } + + public function testNestedMergePropMetadataIsSuppressedByResetHeader(): void + { + $request = $this->makePartialRequest('feed.posts'); + $request->headers->add(['X-Inertia-Reset' => 'feed.posts']); + + $page = $this->makePage($request, [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ]); + + $this->assertSame([['id' => 1]], $page['props']['feed']['posts']); + $this->assertArrayNotHasKey('mergeProps', $page); + } + + public function testNestedOncePropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'config' => [ + 'locale' => Inertia::once(fn () => 'en'), + ], + ]); + + $this->assertSame(['config.locale' => ['prop' => 'config.locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testNestedOncePropWithCustomKeyMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'config' => [ + 'locale' => Inertia::once(fn () => 'en')->as('app-locale'), + ], + ]); + + $this->assertSame(['app-locale' => ['prop' => 'config.locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testNestedOncePropIsExcludedWhenAlreadyLoaded(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'config.locale']); + + $page = $this->makePage($request, [ + 'config' => [ + 'locale' => Inertia::once(fn () => 'en'), + 'timezone' => 'UTC', + ], + ]); + + $this->assertSame('UTC', $page['props']['config']['timezone']); + $this->assertArrayNotHasKey('locale', $page['props']['config']); + $this->assertSame(['config.locale' => ['prop' => 'config.locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testNestedOnceMetadataIsCollectedOnExactPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('config.locale'), [ + 'config' => [ + 'locale' => Inertia::once(fn () => 'en'), + ], + ]); + + $this->assertSame('en', $page['props']['config']['locale']); + $this->assertSame(['config.locale' => ['prop' => 'config.locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testNestedOnceMetadataIsCollectedWhenParentIsRequested(): void + { + $page = $this->makePage($this->makePartialRequest('config'), [ + 'config' => [ + 'locale' => Inertia::once(fn () => 'en'), + ], + ]); + + $this->assertSame('en', $page['props']['config']['locale']); + $this->assertSame(['config.locale' => ['prop' => 'config.locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testNestedScrollPropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => new ScrollProp( + ['data' => [['id' => 1]]], + 'data', + $this->makeScrollMetadata(), + ), + ], + ]); + + $this->assertSame([ + 'feed.posts' => [ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + 'reset' => false, + ], + ], $page['scrollProps']); + } + + public function testNestedDeferredScrollPropIsExcludedFromInitialLoad(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => (new ScrollProp( + ['data' => [['id' => 1]]], + 'data', + $this->makeScrollMetadata(), + ))->defer(), + ], + ]); + + $this->assertArrayNotHasKey('posts', $page['props']['feed'] ?? []); + $this->assertSame(['default' => ['feed.posts']], $page['deferredProps']); + } + + public function testNestedScrollPropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('feed.posts'), [ + 'feed' => [ + 'posts' => new ScrollProp( + ['data' => [['id' => 1]]], + 'data', + $this->makeScrollMetadata(), + ), + ], + ]); + + $this->assertSame(['data' => [['id' => 1]]], $page['props']['feed']['posts']); + $this->assertSame([ + 'feed.posts' => [ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + 'reset' => false, + ], + ], $page['scrollProps']); + } + + public function testNestedScrollPropResetFlagIsSetByResetHeader(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia-Reset' => 'feed.posts']); + + $page = $this->makePage($request, [ + 'feed' => [ + 'posts' => new ScrollProp( + ['data' => [['id' => 1]]], + 'data', + $this->makeScrollMetadata(), + ), + ], + ]); + + $this->assertTrue($page['scrollProps']['feed.posts']['reset']); + } + + public function testNestedDeferOncePropSuppressesDeferredMetadataWhenAlreadyLoaded(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'feed.posts']); + + $page = $this->makePage($request, [ + 'feed' => [ + 'posts' => Inertia::defer(fn () => [])->once(), + ], + ]); + + $this->assertArrayNotHasKey('deferredProps', $page); + } + + public function testNestedDeferOncePropIncludesDeferredMetadataOnFirstLoad(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => Inertia::defer(fn () => [])->once(), + ], + ]); + + $this->assertSame(['default' => ['feed.posts']], $page['deferredProps']); + $this->assertSame(['feed.posts' => ['prop' => 'feed.posts', 'expiresAt' => null]], $page['onceProps']); + } + + public function testNestedPropsOnNonPartialInertiaRequestBehaveLikeInitialLoad(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + + $page = $this->makePage($request, [ + 'dashboard' => [ + 'stats' => 'visible', + 'feed' => new MergeProp([['id' => 1]]), + 'notifications' => Inertia::defer(fn () => []), + 'settings' => Inertia::optional(fn () => []), + ], + ]); + + $this->assertSame('visible', $page['props']['dashboard']['stats']); + $this->assertSame([['id' => 1]], $page['props']['dashboard']['feed']); + $this->assertArrayNotHasKey('notifications', $page['props']['dashboard']); + $this->assertArrayNotHasKey('settings', $page['props']['dashboard']); + $this->assertSame(['dashboard.feed'], $page['mergeProps']); + $this->assertSame(['default' => ['dashboard.notifications']], $page['deferredProps']); + } + + public function testExceptHeaderSuppressesNestedMergeMetadata(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'TestComponent']); + $request->headers->add(['X-Inertia-Partial-Data' => 'feed.posts,feed.comments']); + $request->headers->add(['X-Inertia-Partial-Except' => 'feed.posts']); + + $page = $this->makePage($request, [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + 'comments' => new MergeProp([['id' => 2]]), + ], + ]); + + $this->assertArrayNotHasKey('posts', $page['props']['feed']); + $this->assertSame([['id' => 2]], $page['props']['feed']['comments']); + $this->assertSame(['feed.comments'], $page['mergeProps']); + } + + public function testExceptHeaderForParentSuppressesAllNestedMetadata(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'TestComponent']); + $request->headers->add(['X-Inertia-Partial-Data' => 'feed,other']); + $request->headers->add(['X-Inertia-Partial-Except' => 'feed']); + + $page = $this->makePage($request, [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + 'other' => 'value', + ]); + + $this->assertArrayNotHasKey('feed', $page['props']); + $this->assertSame('value', $page['props']['other']); + $this->assertArrayNotHasKey('mergeProps', $page); + } + + public function testDeeplyNestedDeferPropIsExcludedWithMetadata(): void + { + $page = $this->makePage(Request::create('/'), [ + 'app' => [ + 'auth' => [ + 'notifications' => Inertia::defer(fn () => [], 'alerts'), + ], + ], + ]); + + $this->assertArrayNotHasKey('notifications', $page['props']['app']['auth']); + $this->assertSame(['alerts' => ['app.auth.notifications']], $page['deferredProps']); + } + + public function testDeeplyNestedMergePropMetadataUsesFullPath(): void + { + $page = $this->makePage(Request::create('/'), [ + 'app' => [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ], + ]); + + $this->assertSame(['app.feed.posts'], $page['mergeProps']); + } + + public function testDeeplyNestedOptionalPropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('app.auth.permissions'), [ + 'app' => [ + 'auth' => [ + 'permissions' => Inertia::optional(fn () => ['admin']), + ], + ], + ]); + + $this->assertSame(['admin'], $page['props']['app']['auth']['permissions']); + } + + public function testMultipleNestedPropTypesAreHandledTogether(): void + { + $page = $this->makePage(Request::create('/'), [ + 'dashboard' => [ + 'stats' => 'visible', + 'feed' => new MergeProp([['id' => 1]]), + 'notifications' => Inertia::defer(fn () => []), + 'settings' => Inertia::optional(fn () => []), + 'locale' => Inertia::once(fn () => 'en'), + ], + ]); + + $this->assertSame('visible', $page['props']['dashboard']['stats']); + $this->assertSame([['id' => 1]], $page['props']['dashboard']['feed']); + $this->assertSame('en', $page['props']['dashboard']['locale']); + $this->assertArrayNotHasKey('notifications', $page['props']['dashboard']); + $this->assertArrayNotHasKey('settings', $page['props']['dashboard']); + $this->assertSame(['dashboard.feed'], $page['mergeProps']); + $this->assertSame(['default' => ['dashboard.notifications']], $page['deferredProps']); + $this->assertSame(['dashboard.locale' => ['prop' => 'dashboard.locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testDeferredPropsAtMixedDepthsCollectCorrectMetadata(): void + { + $page = $this->makePage(Request::create('/'), [ + 'foo' => Inertia::defer(fn () => 'bar'), + 'nested' => [ + 'a' => 'b', + 'c' => Inertia::defer(fn () => 'd'), + ], + ]); + + $this->assertSame('b', $page['props']['nested']['a']); + $this->assertArrayNotHasKey('foo', $page['props']); + $this->assertArrayNotHasKey('c', $page['props']['nested']); + $this->assertSame(['default' => ['foo', 'nested.c']], $page['deferredProps']); + } + + public function testDeferredPropsAtMixedDepthsResolveOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('foo,nested.c'), [ + 'foo' => Inertia::defer(fn () => 'bar'), + 'nested' => [ + 'a' => 'b', + 'c' => Inertia::defer(fn () => 'd'), + ], + ]); + + $this->assertSame('bar', $page['props']['foo']); + $this->assertSame('d', $page['props']['nested']['c']); + $this->assertArrayNotHasKey('a', $page['props']['nested']); + $this->assertArrayNotHasKey('deferredProps', $page); + } + + public function testDotNotationPropMergesIntoExistingNestedStructure(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ], + ], + 'auth.user.permissions' => fn () => ['edit-posts', 'delete-posts'], + ]); + + $this->assertSame('Jonathan Reinink', $page['props']['auth']['user']['name']); + $this->assertSame('jonathan@example.com', $page['props']['auth']['user']['email']); + $this->assertSame(['edit-posts', 'delete-posts'], $page['props']['auth']['user']['permissions']); + $this->assertArrayNotHasKey('auth.user.permissions', $page['props']); + } + + public function testDotNotationPropMergesWhenParentIsAClosure(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => fn () => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ], + ], + 'auth.user.permissions' => fn () => ['edit-posts', 'delete-posts'], + ]); + + $this->assertSame('Jonathan Reinink', $page['props']['auth']['user']['name']); + $this->assertSame('jonathan@example.com', $page['props']['auth']['user']['email']); + $this->assertSame(['edit-posts', 'delete-posts'], $page['props']['auth']['user']['permissions']); + } + + public function testDotNotationOptionalPropIsExcludedFromInitialLoad(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ], + ], + 'auth.user.permissions' => Inertia::optional(fn () => ['edit-posts', 'delete-posts']), + ]); + + $this->assertSame('Jonathan Reinink', $page['props']['auth']['user']['name']); + $this->assertSame('jonathan@example.com', $page['props']['auth']['user']['email']); + $this->assertArrayNotHasKey('permissions', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('auth.user.permissions', $page['props']); + } + + public function testDotNotationOptionalPropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('auth.user.permissions'), [ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ], + ], + 'auth.user.permissions' => Inertia::optional(fn () => ['edit-posts', 'delete-posts']), + ]); + + $this->assertSame(['edit-posts', 'delete-posts'], $page['props']['auth']['user']['permissions']); + $this->assertArrayNotHasKey('auth.user.permissions', $page['props']); + } + + public function testOptionalPropsInsideIndexedArraysAreResolvedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('foos'), [ + 'foos' => [ + [ + 'name' => 'First', + 'bar' => Inertia::optional(fn () => 'expensive-data-1'), + ], + [ + 'name' => 'Second', + 'bar' => Inertia::optional(fn () => 'expensive-data-2'), + ], + ], + ]); + + $this->assertSame('First', $page['props']['foos'][0]['name']); + $this->assertSame('expensive-data-1', $page['props']['foos'][0]['bar']); + $this->assertSame('Second', $page['props']['foos'][1]['name']); + $this->assertSame('expensive-data-2', $page['props']['foos'][1]['bar']); + } + + public function testOptionalPropsInsideIndexedArraysAreExcludedFromInitialLoad(): void + { + $resolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'foos' => [ + [ + 'name' => 'First', + 'bar' => Inertia::optional(function () use (&$resolved) { + $resolved = true; + + return 'expensive-data'; + }), + ], + ], + ]); + + $this->assertSame('First', $page['props']['foos'][0]['name']); + $this->assertArrayNotHasKey('bar', $page['props']['foos'][0]); + $this->assertFalse($resolved, 'OptionalProp closure should not be resolved on initial load'); + } + + public function testDeferredPropsInsideClosureAreExcludedFromInitialLoad(): void + { + $notificationsResolved = false; + $rolesResolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'auth' => fn () => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ], + 'notifications' => Inertia::defer(function () use (&$notificationsResolved) { + $notificationsResolved = true; + + return ['You have a new follower']; + }), + 'roles' => Inertia::defer(function () use (&$rolesResolved) { + $rolesResolved = true; + + return ['admin']; + }), + ], + ]); + + $this->assertSame('Jonathan Reinink', $page['props']['auth']['user']['name']); + $this->assertArrayNotHasKey('notifications', $page['props']['auth']); + $this->assertArrayNotHasKey('roles', $page['props']['auth']); + $this->assertSame(['default' => ['auth.notifications', 'auth.roles']], $page['deferredProps']); + $this->assertFalse($notificationsResolved, 'DeferProp closure should not be resolved on initial load'); // @phpstan-ignore method.impossibleType + $this->assertFalse($rolesResolved, 'DeferProp closure should not be resolved on initial load'); // @phpstan-ignore method.impossibleType + } + + public function testDeferredPropsInsideClosureAreResolvedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('auth.notifications,auth.roles'), [ + 'auth' => fn () => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ], + 'notifications' => Inertia::defer(fn () => ['You have a new follower']), + 'roles' => Inertia::defer(fn () => ['admin']), + ], + ]); + + $this->assertSame(['You have a new follower'], $page['props']['auth']['notifications']); + $this->assertSame(['admin'], $page['props']['auth']['roles']); + } + + public function testArraysMatchingCallableSyntaxAreNotInvoked(): void + { + $page = $this->makePage(Request::create('/'), [ + 'job' => [ + 'name' => 'Import', + 'fields' => ['Context', 'comment'], + ], + ]); + + $this->assertSame(['Context', 'comment'], $page['props']['job']['fields']); + } + + /** + * Resolve the given props through the Inertia response and return the page data. + * + * @param array $props + * @return array + */ + protected function makePage(Request $request, array $props): array + { + $response = new Response('TestComponent', [], $props, 'app', '123'); + $response = $response->toResponse($request); + + if ($response instanceof JsonResponse) { + return $response->getData(true); + } + + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + + return $view->getData()['page']; + } + + /** + * Create a partial Inertia request for the given props. + */ + protected function makePartialRequest(string $only): Request + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'TestComponent']); + $request->headers->add(['X-Inertia-Partial-Data' => $only]); + + return $request; + } + + /** + * Create a scroll metadata provider for testing. + */ + protected function makeScrollMetadata(): ProvidesScrollMetadata + { + return new class implements ProvidesScrollMetadata { + public function getPageName(): string + { + return 'page'; + } + + public function getPreviousPage(): ?int + { + return null; + } + + public function getNextPage(): int + { + return 2; + } + + public function getCurrentPage(): int + { + return 1; + } + }; + } +} diff --git a/tests/Inertia/ResponseFactoryTest.php b/tests/Inertia/ResponseFactoryTest.php new file mode 100644 index 000000000..37795ed6e --- /dev/null +++ b/tests/Inertia/ResponseFactoryTest.php @@ -0,0 +1,1003 @@ +macro('foo', function () { + return 'bar'; + }); + + /* @phpstan-ignore-next-line */ + $this->assertEquals('bar', $factory->foo()); + } + + public function testLocationResponseForInertiaRequests(): void + { + Request::macro('inertia', function () { + return true; + }); + + $response = (new ResponseFactory)->location('https://inertiajs.com'); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(Response::HTTP_CONFLICT, $response->getStatusCode()); + $this->assertEquals('https://inertiajs.com', $response->headers->get('X-Inertia-Location')); + } + + public function testLocationResponseForNonInertiaRequests(): void + { + Request::macro('inertia', function () { + return false; + }); + + $response = (new ResponseFactory)->location('https://inertiajs.com'); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertEquals('https://inertiajs.com', $response->headers->get('location')); + } + + public function testLocationResponseForInertiaRequestsUsingRedirectResponse(): void + { + Request::macro('inertia', function () { + return true; + }); + + $redirect = new RedirectResponse('https://inertiajs.com'); + $response = (new ResponseFactory)->location($redirect); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(409, $response->getStatusCode()); + $this->assertEquals('https://inertiajs.com', $response->headers->get('X-Inertia-Location')); + } + + public function testLocationResponseForNonInertiaRequestsUsingRedirectResponse(): void + { + $redirect = new RedirectResponse('https://inertiajs.com'); + $response = (new ResponseFactory)->location($redirect); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertEquals('https://inertiajs.com', $response->headers->get('location')); + } + + public function testLocationRedirectsAreNotModified(): void + { + $response = (new ResponseFactory)->location('/foo'); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertEquals('/foo', $response->headers->get('location')); + } + + public function testLocationResponseForNonInertiaRequestsUsingRedirectResponseWithExistingSessionAndRequestProperties(): void + { + $redirect = new RedirectResponse('https://inertiajs.com'); + $redirect->setSession($session = new Store('test', new NullSessionHandler)); + $redirect->setRequest($request = new HttpRequest); + $response = (new ResponseFactory)->location($redirect); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertEquals('https://inertiajs.com', $response->headers->get('location')); + $this->assertSame($session, $response->getSession()); + $this->assertSame($request, $response->getRequest()); + $this->assertSame($response, $redirect); + } + + public function testTheVersionCanBeAClosure(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $this->assertSame('', Inertia::getVersion()); + + Inertia::version(function () { + return hash('xxh128', 'Inertia'); + }); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => 'f445bd0a2c393a5af14fc677f59980a9', + ]); + + $response->assertSuccessful(); + $response->assertJson(['component' => 'User/Edit']); + } + + public function testTheUrlCanBeResolvedWithACustomResolver(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::resolveUrlUsing(function ($request, ResponseFactory $otherDependency) { + $this->assertInstanceOf(HttpRequest::class, $request); + $this->assertInstanceOf(ResponseFactory::class, $otherDependency); + + return '/my-custom-url'; + }); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'url' => '/my-custom-url', + ]); + } + + public function testSharedDataCanBeSharedFromAnywhere(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('foo', 'bar'); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'foo' => 'bar', + ], + ]); + } + + public function testDotPropsAreMergedFromShared(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('auth.user', [ + 'name' => 'Jonathan', + ]); + + return Inertia::render('User/Edit', [ + 'auth.user.can.create_group' => false, + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan', + 'can' => [ + 'create_group' => false, + ], + ], + ], + ], + ]); + } + + public function testSharedDataCanResolveClosureArguments(): void + { + Inertia::share('query', fn (HttpRequest $request) => $request->query()); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/?foo=bar', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'query' => [ + 'foo' => 'bar', + ], + ], + ]); + } + + public function testDotPropsWithCallbacksAreMergedFromShared(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('auth.user', fn () => [ + 'name' => 'Jonathan', + ]); + + return Inertia::render('User/Edit', [ + 'auth.user.can.create_group' => false, + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan', + 'can' => [ + 'create_group' => false, + ], + ], + ], + ], + ]); + } + + public function testCanFlushSharedData(): void + { + Inertia::share('foo', 'bar'); + $this->assertSame(['foo' => 'bar'], Inertia::getShared()); + Inertia::flushShared(); + $this->assertSame([], Inertia::getShared()); + } + + public function testCanCreateDeferredProp(): void + { + $factory = new ResponseFactory; + $deferredProp = $factory->defer(function () { + return 'A deferred value'; + }); + + $this->assertInstanceOf(DeferProp::class, $deferredProp); + $this->assertSame($deferredProp->group(), 'default'); + } + + public function testCanCreateDeferredPropWithCustomGroup(): void + { + $factory = new ResponseFactory; + $deferredProp = $factory->defer(function () { + return 'A deferred value'; + }, 'foo'); + + $this->assertInstanceOf(DeferProp::class, $deferredProp); + $this->assertSame($deferredProp->group(), 'foo'); + } + + public function testCanCreateMergedProp(): void + { + $factory = new ResponseFactory; + $mergedProp = $factory->merge(function () { + return 'A merged value'; + }); + + $this->assertInstanceOf(MergeProp::class, $mergedProp); + } + + public function testCanCreateDeepMergedProp(): void + { + $factory = new ResponseFactory; + $mergedProp = $factory->deepMerge(function () { + return 'A merged value'; + }); + + $this->assertInstanceOf(MergeProp::class, $mergedProp); + } + + public function testCanCreateDeferredAndMergedProp(): void + { + $factory = new ResponseFactory; + $deferredProp = $factory->defer(function () { + return 'A deferred + merged value'; + })->merge(); + + $this->assertInstanceOf(DeferProp::class, $deferredProp); + } + + public function testCanCreateDeferredAndDeepMergedProp(): void + { + $factory = new ResponseFactory; + $deferredProp = $factory->defer(function () { + return 'A deferred + merged value'; + })->deepMerge(); + + $this->assertInstanceOf(DeferProp::class, $deferredProp); + } + + public function testCanCreateOptionalProp(): void + { + $factory = new ResponseFactory; + $optionalProp = $factory->optional(function () { + return 'An optional value'; + }); + + $this->assertInstanceOf(OptionalProp::class, $optionalProp); + } + + public function testCanCreateScrollProp(): void + { + $factory = new ResponseFactory; + $data = ['item1', 'item2']; + + $scrollProp = $factory->scroll($data); + + $this->assertInstanceOf(ScrollProp::class, $scrollProp); + $this->assertSame($data, $scrollProp()); + } + + public function testCanCreateScrollPropWithMetadataProvider(): void + { + $factory = new ResponseFactory; + $data = ['item1', 'item2']; + $metadataProvider = new ScrollMetadata('custom', 1, 3, 2); + + $scrollProp = $factory->scroll($data, 'data', $metadataProvider); + + $this->assertInstanceOf(ScrollProp::class, $scrollProp); + $this->assertSame($data, $scrollProp()); + $this->assertEquals([ + 'pageName' => 'custom', + 'previousPage' => 1, + 'nextPage' => 3, + 'currentPage' => 2, + ], $scrollProp->metadata()); + } + + public function testCanCreateOnceProp(): void + { + $factory = new ResponseFactory; + $onceProp = $factory->once(function () { + return 'A once value'; + }); + + $this->assertInstanceOf(OnceProp::class, $onceProp); + } + + public function testCanCreateDeferredAndOnceProp(): void + { + $factory = new ResponseFactory; + $deferredProp = $factory->defer(function () { + return 'A deferred + once value'; + })->once(); + + $this->assertInstanceOf(DeferProp::class, $deferredProp); + $this->assertTrue($deferredProp->shouldResolveOnce()); + } + + public function testCanCreateAlwaysProp(): void + { + $factory = new ResponseFactory; + $alwaysProp = $factory->always(function () { + return 'An always value'; + }); + + $this->assertInstanceOf(AlwaysProp::class, $alwaysProp); + } + + public function testWillAcceptArrayabeProps(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('foo', 'bar'); + + return Inertia::render('User/Edit', new class implements Arrayable { + public function toArray(): array + { + return [ + 'foo' => 'bar', + ]; + } + }); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'foo' => 'bar', + ], + ]); + } + + public function testWillAcceptInstancesOfProvidesInertiaProps(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit', new ExampleInertiaPropsProvider([ + 'foo' => 'bar', + ])); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'foo' => 'bar', + ], + ]); + } + + public function testWillAcceptArraysContainingProvidesInertiaPropsInRender(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit', [ + 'regular' => 'prop', + new ExampleInertiaPropsProvider([ + 'from_object' => 'value', + ]), + 'another' => 'normal_prop', + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'regular' => 'prop', + 'from_object' => 'value', + 'another' => 'normal_prop', + ], + ]); + } + + public function testCanShareInstancesOfProvidesInertiaProps(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share(new ExampleInertiaPropsProvider([ + 'shared' => 'data', + ])); + + return Inertia::render('User/Edit', [ + 'regular' => 'prop', + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'shared' => 'data', + 'regular' => 'prop', + ], + ]); + } + + public function testCanShareArraysContainingProvidesInertiaProps(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share([ + 'regular' => 'shared_prop', + new ExampleInertiaPropsProvider([ + 'from_object' => 'shared_value', + ]), + ]); + + return Inertia::render('User/Edit', [ + 'component' => 'prop', + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'regular' => 'shared_prop', + 'from_object' => 'shared_value', + 'component' => 'prop', + ], + ]); + } + + public function testWillThrowExceptionIfComponentDoesNotExistWhenEnsuringIsEnabled(): void + { + config()->set('inertia.pages.ensure_pages_exist', true); + + $this->expectException(ComponentNotFoundException::class); + $this->expectExceptionMessage('Inertia page component [foo] not found.'); + + (new ResponseFactory)->render('foo'); + } + + public function testWillNotThrowExceptionIfComponentDoesNotExistWhenEnsuringIsDisabled(): void + { + config()->set('inertia.pages.ensure_pages_exist', false); + + $response = (new ResponseFactory)->render('foo'); + $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response); + } + + public function testCanResolveComponentNameBeforeRendering(): void + { + $calledWith = null; + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () use (&$calledWith) { + Inertia::transformComponentUsing(static function (string $name) use (&$calledWith): string { + $calledWith = $name; + + return "{$name}/Page"; + }); + + return Inertia::render('Fixtures/Example'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'Fixtures/Example/Page', + ]); + $this->assertSame('Fixtures/Example', $calledWith); + } + + public function testResolvedComponentNameIsUsedForPageExistenceChecks(): void + { + $calledWith = null; + + config()->set('inertia.pages.ensure_pages_exist', true); + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () use (&$calledWith) { + Inertia::transformComponentUsing(static function (string $name) use (&$calledWith): string { + $calledWith = $name; + + return "{$name}/Page"; + }); + + return Inertia::render('Fixtures/Example'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $this->assertSame('Fixtures/Example', $calledWith); + } + + public function testRenderAcceptsBackedEnum(): void + { + $response = (new ResponseFactory)->render(StringBackedEnum::UsersIndex); + $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response); + + /** @phpstan-ignore-next-line */ + $getComponent = fn () => $this->component; + $this->assertSame('UsersPage/Index', $getComponent->call($response)); + } + + public function testRenderAcceptsUnitEnum(): void + { + $response = (new ResponseFactory)->render(UnitEnum::Index); + $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response); + + /** @phpstan-ignore-next-line */ + $getComponent = fn () => $this->component; + $this->assertSame('Index', $getComponent->call($response)); + } + + public function testRenderThrowsForNonStringBackedEnum(): void + { + $factory = new ResponseFactory; + $this->expectException(InvalidArgumentException::class); + $factory->render(IntBackedEnum::Zero); + } + + public function testShareOnceSharesAOnceProp(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::shareOnce('settings', fn () => ['theme' => 'dark']); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'settings' => ['theme' => 'dark'], + ], + 'onceProps' => [ + 'settings' => [ + 'prop' => 'settings', + 'expiresAt' => null, + ], + ], + ]); + } + + public function testShareOnceIsChainable(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $prop = Inertia::shareOnce('settings', fn () => ['theme' => 'dark']) + ->as('app-settings') + ->until(60); + + $this->assertInstanceOf(OnceProp::class, $prop); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $data = $response->json(); + + $this->assertArrayHasKey('onceProps', $data); + $this->assertArrayHasKey('app-settings', $data['onceProps']); + $this->assertEquals('settings', $data['onceProps']['app-settings']['prop']); + $this->assertNotNull($data['onceProps']['app-settings']['expiresAt']); + } + + public function testForcefullyRefreshingAOncePropIncludesItInOnceProps(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit', [ + 'settings' => Inertia::once(fn () => ['theme' => 'dark'])->fresh(), + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'settings' => ['theme' => 'dark'], + ], + 'onceProps' => [ + 'settings' => ['prop' => 'settings', 'expiresAt' => null], + ], + ]); + } + + public function testOncePropIsIncludedInOncePropsByDefault(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit', [ + 'settings' => Inertia::once(fn () => ['theme' => 'dark']), + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'settings' => ['theme' => 'dark'], + ], + 'onceProps' => [ + 'settings' => [ + 'prop' => 'settings', + 'expiresAt' => null, + ], + ], + ]); + } + + public function testFlashDataIsFlashedToSessionOnRedirect(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->post('/flash-test', function () { + return Inertia::flash(['message' => 'Success!'])->back(); + }); + + $response = $this->post('/flash-test', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertRedirect(); + $this->assertEquals(['message' => 'Success!'], session('inertia.flash_data')); + } + + public function testRenderWithFlashIncludesFlashInPage(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->post('/flash-test', function () { + return Inertia::flash('type', 'success') + ->render('User/Edit', ['user' => 'Jonathan']) + ->flash(['message' => 'User updated!']); + }); + + $response = $this->post('/flash-test', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'user' => 'Jonathan', + ], + 'flash' => [ + 'message' => 'User updated!', + 'type' => 'success', + ], + ]); + + // Flash data should not persist in session after being included in response + $this->assertNull(session('inertia.flash_data')); + } + + public function testRenderWithoutFlashDoesNotIncludeFlashKey(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/no-flash', function () { + return Inertia::render('User/Edit', ['user' => 'Jonathan']); + }); + + $response = $this->get('/no-flash', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + ]); + $response->assertJsonMissing(['flash']); + } + + public function testMultipleFlashCallsAreMerged(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->post('/create', function () { + Inertia::flash('foo', 'value1'); + Inertia::flash('bar', 'value2'); + + return Inertia::render('User/Show'); + }); + + $response = $this->post('/create', [], ['X-Inertia' => 'true']); + + $response->assertJson([ + 'flash' => [ + 'foo' => 'value1', + 'bar' => 'value2', + ], + ]); + } + + public function testSharedPropsTrackingCanBeDisabled(): void + { + config()->set('inertia.expose_shared_prop_keys', false); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('app_name', 'My App'); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $data = $response->json(); + $this->assertArrayNotHasKey('sharedProps', $data); + $this->assertSame('My App', $data['props']['app_name']); + } + + public function testSharedPropsMetadataIncludesKeysFromMiddlewareShare(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit', [ + 'user' => ['name' => 'Jonathan'], + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'user' => ['name' => 'Jonathan'], + ], + 'sharedProps' => ['errors'], + ]); + } + + public function testSharedPropsMetadataIncludesKeysFromInertiaShare(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('app_name', 'My App'); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'sharedProps' => ['errors', 'app_name'], + ]); + } + + public function testSharedPropsMetadataIncludesDotNotationKeysAsTopLevel(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('auth.user', ['name' => 'Jonathan']); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'sharedProps' => ['errors', 'auth'], + ]); + } + + public function testSharedPropsMetadataIncludesKeysFromShareOnce(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::shareOnce('permissions', fn () => ['admin' => true]); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'props' => [ + 'permissions' => ['admin' => true], + ], + 'sharedProps' => ['errors', 'permissions'], + ]); + } + + public function testSharedPropsMetadataIncludesKeysFromProvidesInertiaProperties(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share(new ExampleInertiaPropsProvider([ + 'app_name' => 'My App', + 'locale' => 'en', + ])); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'props' => [ + 'app_name' => 'My App', + 'locale' => 'en', + ], + 'sharedProps' => ['errors', 'app_name', 'locale'], + ]); + } + + public function testSharedPropsMetadataIncludesPageSpecificOverrideKeys(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('auth', ['user' => null]); + + return Inertia::render('User/Edit', [ + 'auth' => ['user' => ['name' => 'Jonathan']], + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'props' => [ + 'auth' => ['user' => ['name' => 'Jonathan']], + ], + 'sharedProps' => ['errors', 'auth'], + ]); + } + + public function testSharedPropsMetadataWithMultipleShareCalls(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('app_name', 'My App'); + Inertia::share('locale', 'en'); + Inertia::shareOnce('permissions', fn () => ['admin' => true]); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'sharedProps' => ['errors', 'app_name', 'locale', 'permissions'], + ]); + } + + public function testSharedPropsMetadataWithArrayShare(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share([ + 'flash' => fn () => ['message' => 'Hello'], + 'auth' => fn () => ['user' => ['name' => 'Jonathan']], + ]); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'sharedProps' => ['errors', 'flash', 'auth'], + ]); + } + + public function testSharedPropsMetadataIncludesAlreadyLoadedOnceProps(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::shareOnce('permissions', fn () => ['admin' => true]); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Except-Once-Props' => 'permissions', + ]); + + $response->assertSuccessful(); + $data = $response->json(); + + // The once-prop value should be excluded from props since the client already has it + $this->assertArrayNotHasKey('permissions', $data['props']); + + // But its key should still appear in the sharedProps metadata + $this->assertContains('permissions', $data['sharedProps']); + + // And its onceProps metadata should also be preserved + $this->assertArrayHasKey('permissions', $data['onceProps']); + } + + public function testWithoutSsrRegistersPathsWithGateway(): void + { + Inertia::withoutSsr(['admin/*', 'nova/*']); + + $this->assertContains('admin/*', app(HttpGateway::class)->getExcludedPaths()); + $this->assertContains('nova/*', app(HttpGateway::class)->getExcludedPaths()); + } + + public function testWithoutSsrAcceptsString(): void + { + Inertia::withoutSsr('admin/*'); + + $this->assertContains('admin/*', app(HttpGateway::class)->getExcludedPaths()); + } +} diff --git a/tests/Inertia/ResponseTest.php b/tests/Inertia/ResponseTest.php new file mode 100644 index 000000000..661ae1049 --- /dev/null +++ b/tests/Inertia/ResponseTest.php @@ -0,0 +1,1889 @@ +macro('foo', function () { + return 'bar'; + }); + + /* @phpstan-ignore-next-line */ + $this->assertEquals('bar', $response->foo()); + } + + public function testServerResponse(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response('User/Edit', [], ['user' => $user], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithDeferredProp(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => new DeferProp(function () { + return 'bar'; + }, 'default'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'default' => ['foo'], + ], $page['deferredProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithDeferredPropAndMultipleGroups(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => new DeferProp(function () { + return 'foo value'; + }, 'default'), + 'bar' => new DeferProp(function () { + return 'bar value'; + }, 'default'), + 'baz' => new DeferProp(function () { + return 'baz value'; + }, 'custom'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'default' => ['foo', 'bar'], + 'custom' => ['baz'], + ], $page['deferredProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + #[DataProvider('resetUsersProp')] + public function testServerResponseWithScrollProps(bool $resetUsersProp): void + { + $request = Request::create('/user/123', 'GET'); + + if ($resetUsersProp) { + $request->headers->add(['X-Inertia-Reset' => 'users']); + } + + $response = new Response( + 'User/Index', + [], + [ + 'users' => new ScrollProp(['data' => [['id' => 1]]], 'data', new class implements ProvidesScrollMetadata { + public function getPageName(): string + { + return 'page'; + } + + public function getPreviousPage(): ?int + { + return null; + } + + public function getNextPage(): int + { + return 2; + } + + public function getCurrentPage(): int + { + return 1; + } + }), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Index', $page['component']); + $this->assertSame(['data' => [['id' => 1]]], $page['props']['users']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'users' => [ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + 'reset' => $resetUsersProp, + ], + ], $page['scrollProps']); + } + + /** + * @return array + */ + public static function resetUsersProp(): array + { + return [ + 'no reset' => [false], + 'with reset' => [true], + ]; + } + + public function testServerResponseWithMergeProps(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => new MergeProp('foo value'), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'foo', + 'bar', + ], $page['mergeProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithMergePropsThatShouldPrepend(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new MergeProp('foo value'))->prepend(), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame(['bar'], $page['mergeProps']); + $this->assertSame(['foo'], $page['prependProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithMergePropsThatHasNestedPathsToAppendAndPrepend(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new MergeProp(['data' => [['id' => 1], ['id' => 2]]]))->append('data'), + 'bar' => (new MergeProp(['data' => ['items' => [['uuid' => 1], ['uuid' => 2]]]]))->prepend('data.items'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame(['foo.data'], $page['mergeProps']); + $this->assertSame(['bar.data.items'], $page['prependProps']); + $this->assertArrayNotHasKey('matchPropsOn', $page); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithMergePropsThatHasNestedPathsToAppendAndPrependWithMatchOnStrategies(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new MergeProp(['data' => [['id' => 1], ['id' => 2]]]))->append('data', 'id'), + 'bar' => (new MergeProp(['data' => ['items' => [['uuid' => 1], ['uuid' => 2]]]]))->prepend('data.items', 'uuid'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame(['foo.data'], $page['mergeProps']); + $this->assertSame(['bar.data.items'], $page['prependProps']); + $this->assertSame(['foo.data.id', 'bar.data.items.uuid'], $page['matchPropsOn']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithDeepMergeProps(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new MergeProp('foo value'))->deepMerge(), + 'bar' => (new MergeProp('bar value'))->deepMerge(), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'foo', + 'bar', + ], $page['deepMergeProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithMatchOnProps(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new MergeProp('foo value'))->matchOn('foo-key')->deepMerge(), + 'bar' => (new MergeProp('bar value'))->matchOn('bar-key')->deepMerge(), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'foo', + 'bar', + ], $page['deepMergeProps']); + + $this->assertSame([ + 'foo.foo-key', + 'bar.bar-key', + ], $page['matchPropsOn']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithDeferAndMergeProps(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new DeferProp(function () { + return 'foo value'; + }, 'default'))->merge(), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'default' => ['foo'], + ], $page['deferredProps']); + $this->assertSame([ + 'foo', + 'bar', + ], $page['mergeProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithDeferAndDeepMergeProps(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new DeferProp(function () { + return 'foo value'; + }, 'default'))->deepMerge(), + 'bar' => (new MergeProp('bar value'))->deepMerge(), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'default' => ['foo'], + ], $page['deferredProps']); + $this->assertSame([ + 'foo', + 'bar', + ], $page['deepMergeProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testExcludeMergePropsFromPartialOnlyResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'user']); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => new MergeProp('foo value'), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $props = get_object_vars($page->props); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('Jonathan', $props['user']->name); + $this->assertArrayNotHasKey('foo', $props); + $this->assertArrayNotHasKey('bar', $props); + $this->assertFalse(isset($page->mergeProps)); + } + + public function testExcludeMergePropsFromPartialExceptResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Except' => 'foo']); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => new MergeProp('foo value'), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $props = get_object_vars($page->props); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('Jonathan', $props['user']->name); + $this->assertArrayNotHasKey('foo', $props); + $this->assertArrayHasKey('bar', $props); + $this->assertSame(['bar'], $page->mergeProps); + } + + public function testExcludeMergePropsWhenPassedInResetHeader(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'foo']); + $request->headers->add(['X-Inertia-Reset' => 'foo']); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => new MergeProp('foo value'), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $props = get_object_vars($page->props); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame($props['foo'], 'foo value'); + $this->assertArrayNotHasKey('bar', $props); + $this->assertFalse(isset($page->mergeProps)); + } + + public function testXhrResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $user = (object) ['name' => 'Jonathan']; + $response = new Response('User/Edit', [], ['user' => $user], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertSame('Jonathan', $page->props->user->name); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + + public function testXhrResponseWithDeferredPropsIncludesDeferredMetadata(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + 'results' => new DeferProp(fn () => ['data' => ['item1', 'item2']], 'default'), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertSame('Jonathan', $page->props->user->name); + $this->assertFalse(property_exists($page->props, 'results')); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) ['default' => ['results']], $page->deferredProps); + } + + public function testResourceResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $resource = new FakeResource(['name' => 'Jonathan']); + + $response = new Response('User/Edit', [], ['user' => $resource], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertSame('Jonathan', $page->props->user->name); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + + public function testOptionalCallableResourceResponse(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Index', [], [ + 'users' => fn () => [['name' => 'Jonathan']], + 'organizations' => fn () => [['name' => 'Inertia']], + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Index', $page->component); + $this->assertSame('/users', $page->url); + $this->assertSame('123', $page->version); + tap($page->props->users, function ($users) { + $this->assertSame(json_encode([['name' => 'Jonathan']]), json_encode($users)); + }); + tap($page->props->organizations, function ($organizations) { + $this->assertSame(json_encode([['name' => 'Inertia']]), json_encode($organizations)); + }); + } + + public function testOptionalCallableResourcePartialResponse(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Data' => 'users']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Index']); + + $response = new Response('User/Index', [], [ + 'users' => fn () => [['name' => 'Jonathan']], + 'organizations' => fn () => [['name' => 'Inertia']], + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Index', $page->component); + $this->assertSame('/users', $page->url); + $this->assertSame('123', $page->version); + $this->assertFalse(property_exists($page->props, 'organizations')); + tap($page->props->users, function ($users) { + $this->assertSame(json_encode([['name' => 'Jonathan']]), json_encode($users)); + }); + } + + public function testOptionalResourceResponse(): void + { + $request = Request::create('/users', 'GET', ['page' => 1]); + $request->headers->add(['X-Inertia' => 'true']); + + $users = Collection::make([ + new Fluent(['name' => 'Jonathan']), + new Fluent(['name' => 'Taylor']), + new Fluent(['name' => 'Jeffrey']), + ]); + + $callable = static function () use ($users) { + $page = new LengthAwarePaginator($users->take(2), $users->count(), 2); + + return new class($page) extends ResourceCollection {}; + }; + + $response = new Response('User/Index', [], ['users' => $callable], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $expected = [ + 'data' => $users->take(2), + 'links' => [ + 'first' => '/?page=1', + 'last' => '/?page=2', + 'prev' => null, + 'next' => '/?page=2', + ], + 'meta' => [ + 'current_page' => 1, + 'from' => 1, + 'last_page' => 2, + 'path' => '/', + 'per_page' => 2, + 'to' => 2, + 'total' => 3, + ], + ]; + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Index', $page->component); + $this->assertSame('/users?page=1', $page->url); + $this->assertSame('123', $page->version); + tap($page->props->users, function ($users) use ($expected) { + $this->assertSame(json_encode($expected['data']), json_encode($users->data)); + $this->assertSame(json_encode($expected['links']), json_encode($users->links)); + $this->assertSame('/', $users->meta->path); + }); + } + + public function testNestedOptionalResourceResponse(): void + { + $request = Request::create('/users', 'GET', ['page' => 1]); + $request->headers->add(['X-Inertia' => 'true']); + + $users = Collection::make([ + new Fluent(['name' => 'Jonathan']), + new Fluent(['name' => 'Taylor']), + new Fluent(['name' => 'Jeffrey']), + ]); + + $callable = static function () use ($users) { + $page = new LengthAwarePaginator($users->take(2), $users->count(), 2); + + // nested array with ResourceCollection to resolve + return [ + 'users' => new class($page) extends ResourceCollection {}, + ]; + }; + + $response = new Response('User/Index', [], ['something' => $callable], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $expected = [ + 'users' => [ + 'data' => $users->take(2), + 'links' => [ + 'first' => '/?page=1', + 'last' => '/?page=2', + 'prev' => null, + 'next' => '/?page=2', + ], + 'meta' => [ + 'current_page' => 1, + 'from' => 1, + 'last_page' => 2, + 'path' => '/', + 'per_page' => 2, + 'to' => 2, + 'total' => 3, + ], + ], + ]; + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Index', $page->component); + $this->assertSame('/users?page=1', $page->url); + $this->assertSame('123', $page->version); + tap($page->props->something->users, function ($users) use ($expected) { + $this->assertSame(json_encode($expected['users']['data']), json_encode($users->data)); + $this->assertSame(json_encode($expected['users']['links']), json_encode($users->links)); + $this->assertSame('/', $users->meta->path); + }); + } + + public function testArrayablePropResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $resource = FakeResource::make(['name' => 'Jonathan']); + + $response = new Response('User/Edit', [], ['user' => $resource], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertSame('Jonathan', $page->props->user->name); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + + public function testPromisePropsAreResolved(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $user = (object) ['name' => 'Jonathan']; + + $promise = Mockery::mock('GuzzleHttp\Promise\PromiseInterface') + ->shouldReceive('wait') + ->andReturn($user) + ->getMock(); + + $response = new Response('User/Edit', [], ['user' => $promise], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertSame('Jonathan', $page->props->user->name); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + + public function testXhrPartialResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'partial']); + + $user = (object) ['name' => 'Jonathan']; + $response = new Response('User/Edit', [], ['user' => $user, 'partial' => 'partial-data'], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $props = get_object_vars($page->props); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertFalse(isset($props['user'])); + $this->assertCount(1, $props); + $this->assertSame('partial-data', $page->props->partial); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + + public function testExcludePropsFromPartialResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Except' => 'user']); + + $user = (object) ['name' => 'Jonathan']; + $response = new Response('User/Edit', [], ['user' => $user, 'partial' => 'partial-data'], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $props = get_object_vars($page->props); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertFalse(isset($props['user'])); + $this->assertCount(1, $props); + $this->assertSame('partial-data', $page->props->partial); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + + public function testNestedClosuresAreResolved(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'auth' => [ + 'user' => fn () => ['name' => 'Jonathan'], + 'token' => 'value', + ], + ], 'app', '123'); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['auth']['user']['name']); + $this->assertSame('value', $page['props']['auth']['token']); + } + + public function testDoubleNestedClosuresAreResolved(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'auth' => fn () => [ + 'user' => fn () => ['name' => 'Jonathan'], + 'token' => 'value', + ], + ], 'app', '123'); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['auth']['user']['name']); + $this->assertSame('value', $page['props']['auth']['token']); + } + + public function testNestedOptionalPropInsideClosureIsExcluded(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'auth' => fn () => [ + 'user' => ['name' => 'Jonathan'], + 'pending' => Inertia::optional(fn () => 'secret'), + ], + ], 'app', '123'); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['auth']['user']['name']); + $this->assertArrayNotHasKey('pending', $page['props']['auth']); + } + + public function testNestedPartialProps(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'auth.user,auth.refresh_token']); + + $props = [ + 'auth' => [ + 'user' => new OptionalProp(function () { + return [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ]; + }), + 'refresh_token' => 'value', + 'token' => 'value', + ], + 'shared' => [ + 'flash' => 'value', + ], + ]; + + $response = new Response('User/Edit', [], $props); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertFalse(isset($page->props->shared)); + $this->assertFalse(isset($page->props->auth->token)); + $this->assertSame('Jonathan Reinink', $page->props->auth->user->name); + $this->assertSame('jonathan@example.com', $page->props->auth->user->email); + $this->assertSame('value', $page->props->auth->refresh_token); + } + + public function testExcludeNestedPropsFromPartialResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'auth']); + $request->headers->add(['X-Inertia-Partial-Except' => 'auth.user']); + + $props = [ + 'auth' => [ + 'user' => new OptionalProp(function () { + return [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ]; + }), + 'refresh_token' => 'value', + ], + 'shared' => [ + 'flash' => 'value', + ], + ]; + + $response = new Response('User/Edit', [], $props); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertFalse(isset($page->props->auth->user)); + $this->assertFalse(isset($page->props->shared)); + $this->assertSame('value', $page->props->auth->refresh_token); + } + + public function testOptionalPropsAreNotIncludedByDefault(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $optionalProp = new OptionalProp(function () { + return 'An optional value'; + }); + + $response = new Response('Users', [], ['users' => [], 'optional' => $optionalProp], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame([], $page->props->users); + $this->assertFalse(property_exists($page->props, 'optional')); + } + + public function testOptionalPropsAreIncludedInPartialReload(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'Users']); + $request->headers->add(['X-Inertia-Partial-Data' => 'optional']); + + $optionalProp = new OptionalProp(function () { + return 'An optional value'; + }); + + $response = new Response('Users', [], ['users' => [], 'optional' => $optionalProp], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertFalse(property_exists($page->props, 'users')); + $this->assertSame('An optional value', $page->props->optional); + } + + public function testDeferArrayablePropsAreResolvedInPartialReload(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'Users']); + $request->headers->add(['X-Inertia-Partial-Data' => 'defer']); + + $deferProp = new DeferProp(function () { + return new class implements Arrayable { + public function toArray(): array + { + return ['foo' => 'bar']; + } + }; + }); + + $response = new Response('Users', [], ['users' => [], 'defer' => $deferProp], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertFalse(property_exists($page->props, 'users')); + $this->assertEquals((object) ['foo' => 'bar'], $page->props->defer); + } + + public function testAlwaysPropsAreIncludedOnPartialReload(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'data']); + + $props = [ + 'user' => new OptionalProp(function () { + return [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ]; + }), + 'data' => [ + 'name' => 'Taylor Otwell', + ], + 'errors' => new AlwaysProp(function () { + return [ + 'name' => 'The email field is required.', + ]; + }), + ]; + + $response = new Response('User/Edit', [], $props, 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame('The email field is required.', $page->props->errors->name); + $this->assertSame('Taylor Otwell', $page->props->data->name); + $this->assertFalse(isset($page->props->user)); + } + + public function testStringFunctionNamesAreNotInvokedAsCallables(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'always' => new AlwaysProp('date'), + 'merge' => new MergeProp('trim'), + ], 'app', '123'); + + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getOriginalContent()->getData()['page']; + + $this->assertSame('date', $page['props']['always']); + $this->assertSame('trim', $page['props']['merge']); + } + + public function testInertiaResponsableObjects(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'foo' => 'bar', + new class implements ProvidesInertiaProperties { + /** + * @return Collection + */ + public function toInertiaProperties(RenderContext $context): iterable + { + return collect([ + 'baz' => 'qux', + ]); + } + }, + 'quux' => 'corge', + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('bar', $page['props']['foo']); + $this->assertSame('qux', $page['props']['baz']); + $this->assertSame('corge', $page['props']['quux']); + } + + public function testInertiaResponseTypeProp(): void + { + $request = Request::create('/user/123', 'GET'); + + Inertia::share('items', ['foo']); + Inertia::share('deep.foo.bar', ['foo']); + + $response = new Response('User/Edit', [], [ + 'items' => new MergeWithSharedProp(['bar']), + 'deep' => [ + 'foo' => [ + 'bar' => new MergeWithSharedProp(['baz']), + ], + ], + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame(['foo', 'bar'], $page['props']['items']); + $this->assertSame(['foo', 'baz'], $page['props']['deep']['foo']['bar']); + } + + public function testTopLevelDotPropsGetUnpacked(): void + { + $props = [ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + ], + ], + 'auth.user.can' => [ + 'do.stuff' => true, + ], + 'product' => ['name' => 'My example product'], + ]; + + $request = Request::create('/products/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Edit', [], $props, 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(true); + + $user = $page['props']['auth']['user']; + $this->assertSame('Jonathan Reinink', $user['name']); + $this->assertTrue($user['can']['do.stuff']); + $this->assertFalse(array_key_exists('auth.user.can', $page['props'])); + } + + public function testNestedDotPropsDoNotGetUnpacked(): void + { + $props = [ + 'auth' => [ + 'user.can' => [ + 'do.stuff' => true, + ], + 'user' => [ + 'name' => 'Jonathan Reinink', + ], + ], + 'product' => ['name' => 'My example product'], + ]; + + $request = Request::create('/products/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Edit', [], $props, 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(true); + + $auth = $page['props']['auth']; + $this->assertSame('Jonathan Reinink', $auth['user']['name']); + $this->assertTrue($auth['user.can']['do.stuff']); + $this->assertFalse(array_key_exists('can', $auth)); + } + + public function testPropsCanBeAddedUsingTheWithMethod(): void + { + $request = Request::create('/user/123', 'GET'); + $response = new Response('User/Edit', [], [], 'app', '123'); + + $response->with(['foo' => 'bar', 'baz' => 'qux']) + ->with(['quux' => 'corge']) + ->with(new class implements ProvidesInertiaProperties { + /** + * @return Collection + */ + public function toInertiaProperties(RenderContext $context): iterable + { + return collect(['grault' => 'garply']); + } + }); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('bar', $page['props']['foo']); + $this->assertSame('qux', $page['props']['baz']); + $this->assertSame('corge', $page['props']['quux']); + } + + public function testOncePropsAreAlwaysResolvedOnInitialPageLoad(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('bar', $page['props']['foo']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame(['foo' => ['prop' => 'foo', 'expiresAt' => null]], $page['onceProps']); + $this->assertSame('
', $view->render()); + } + + public function testFreshOncePropsAreIncludedOnInitialPageLoad(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')->fresh()], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('bar', $page['props']['foo']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertArrayHasKey('onceProps', $page); + $this->assertSame(['foo' => ['prop' => 'foo', 'expiresAt' => null]], $page['onceProps']); + } + + public function testOncePropsAreResolvedWithACustomKeyAndTtlValue(): void + { + $this->freezeSecond(); + + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Edit', [], [ + 'foo' => Inertia::once(fn () => 'bar')->as('baz')->until(now()->addMinute()), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) ['baz' => (object) ['prop' => 'foo', 'expiresAt' => now()->addMinute()->getTimestampMs()]], $page->onceProps); + } + + public function testOncePropsAreNotResolvedOnSubsequentRequestsWhenTheyAreInTheOncePropsHeader(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'foo']); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertArrayNotHasKey('foo', (array) $page->props); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) ['foo' => (object) ['prop' => 'foo', 'expiresAt' => null]], $page->onceProps); + } + + public function testOncePropsAreResolvedOnSubsequentRequestsWhenTheOncePropsHeaderIsMissing(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) ['foo' => (object) ['prop' => 'foo', 'expiresAt' => null]], $page->onceProps); + } + + public function testOncePropsAreResolvedOnSubsequentRequestsWhenTheyAreNotInTheOncePropsHeader(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'baz']); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) ['foo' => (object) ['prop' => 'foo', 'expiresAt' => null]], $page->onceProps); + } + + public function testOncePropsAreResolvedOnPartialRequestsWithoutOnlyOrExcept(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'foo']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'foo']); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) ['foo' => (object) ['prop' => 'foo', 'expiresAt' => null]], $page->onceProps); + } + + public function testOncePropsAreResolvedOnPartialRequestsWhenIncludedInOnlyHeaders(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'foo']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'foo']); + + $response = new Response('User/Edit', [], [ + 'foo' => Inertia::once(fn () => 'bar'), + 'baz' => Inertia::once(fn () => 'qux'), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertFalse(isset($page->props->baz)); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) [ + 'foo' => (object) ['prop' => 'foo', 'expiresAt' => null], + ], $page->onceProps); + } + + public function testOncePropsAreNotResolvedOnPartialRequestsWhenExcludedInExceptHeaders(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Except' => 'foo']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'foo']); + + $response = new Response('User/Edit', [], [ + 'foo' => Inertia::once(fn () => 'bar'), + 'baz' => Inertia::once(fn () => 'qux'), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertFalse(isset($page->props->foo)); + $this->assertSame('qux', $page->props->baz); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) [ + 'baz' => (object) ['prop' => 'baz', 'expiresAt' => null], + ], $page->onceProps); + } + + public function testFreshPropsAreResolvedEvenWhenInExceptOncePropsHeader(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'foo']); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')->fresh()], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) [ + 'foo' => (object) ['prop' => 'foo', 'expiresAt' => null], + ], $page->onceProps); + } + + public function testFreshPropsAreNotExcludedWhileOncePropsAreExcluded(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'foo,baz']); + + $response = new Response('User/Edit', [], [ + 'foo' => Inertia::once(fn () => 'bar')->fresh(), + 'baz' => Inertia::once(fn () => 'qux'), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertFalse(isset($page->props->baz)); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) [ + 'foo' => (object) ['prop' => 'foo', 'expiresAt' => null], + 'baz' => (object) ['prop' => 'baz', 'expiresAt' => null], + ], $page->onceProps); + } + + public function testDeferPropsThatAreOnceAndAlreadyLoadedAreExcluded(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'defer']); + + $response = new Response('User/Edit', [], [ + 'defer' => Inertia::defer(fn () => 'value')->once(), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertFalse(isset($page->props->defer)); + $this->assertFalse(isset($page->deferredProps)); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) [ + 'defer' => (object) ['prop' => 'defer', 'expiresAt' => null], + ], $page->onceProps); + } + + public function testDeferPropsThatAreOnceAndAlreadyLoadedNotExcludedWhenExplicitlyRequested(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'defer']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'defer']); + + $response = new Response('User/Edit', [], [ + 'defer' => Inertia::defer(fn () => 'value')->once(), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('value', $page->props->defer); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertFalse(isset($page->deferredProps)); + $this->assertEquals((object) [ + 'defer' => (object) ['prop' => 'defer', 'expiresAt' => null], + ], $page->onceProps); + } + + public function testResponsableWithInvalidKey(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $resource = new FakeResource(["\x00*\x00_invalid_key" => 'for object']); + + $response = new Response('User/Edit', [], ['resource' => $resource], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(true); + + $this->assertSame( + ["\x00*\x00_invalid_key" => 'for object'], + $page['props']['resource'] + ); + } + + public function testThePageUrlIsPrefixedWithTheProxyPrefix(): void + { + Request::setTrustedProxies(['1.2.3.4'], Request::HEADER_X_FORWARDED_PREFIX); + + $request = Request::create('/user/123', 'GET'); + $request->server->set('REMOTE_ADDR', '1.2.3.4'); + $request->headers->set('X_FORWARDED_PREFIX', '/sub/directory'); + + $user = ['name' => 'Jonathan']; + $response = new Response('User/Edit', [], ['user' => $user], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('/sub/directory/user/123', $page['url']); + } + + public function testThePageUrlDoesntDoubleUp(): void + { + $request = Request::create('/subpath/product/123', 'GET', [], [], [], [ + 'SCRIPT_FILENAME' => '/project/public/index.php', + 'SCRIPT_NAME' => '/subpath/index.php', + ]); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('Product/Show', [], []); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame('/subpath/product/123', $page->url); + } + + public function testTrailingSlashesInAUrlArePreserved(): void + { + $request = Request::create('/users/', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Index', [], []); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame('/users/', $page->url); + } + + public function testTrailingSlashesInAUrlWithQueryParametersArePreserved(): void + { + $request = Request::create('/users/?page=1&sort=name', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Index', [], []); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame('/users/?page=1&sort=name', $page->url); + } + + public function testAUrlWithoutTrailingSlashIsResolvedCorrectly(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Index', [], []); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame('/users', $page->url); + } + + public function testAUrlWithoutTrailingSlashAndQueryParametersIsResolvedCorrectly(): void + { + $request = Request::create('/users?page=1&sort=name', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Index', [], []); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame('/users?page=1&sort=name', $page->url); + } + + public function testDeferredPropsFromProvidesInertiaPropertiesAreIncludedInDeferredPropsMetadata(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'foo' => new DeferProp(fn () => 'bar', 'default'), + ]; + } + }, + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertArrayNotHasKey('foo', $page['props']); + $this->assertSame([ + 'default' => ['foo'], + ], $page['deferredProps']); + } + + public function testDeferredPropsFromProvidesInertiaPropertiesWithMultipleGroups(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'foo' => new DeferProp(fn () => 'foo value', 'default'), + 'bar' => new DeferProp(fn () => 'bar value', 'custom'), + ]; + } + }, + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertArrayNotHasKey('foo', $page['props']); + $this->assertArrayNotHasKey('bar', $page['props']); + $this->assertSame([ + 'default' => ['foo'], + 'custom' => ['bar'], + ], $page['deferredProps']); + } + + public function testDeferredPropsFromProvidesInertiaPropertiesCanBeLoadedViaPartialRequest(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'foo']); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'foo' => new DeferProp(fn () => 'bar', 'default'), + ]; + } + }, + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(true); + + $this->assertSame('bar', $page['props']['foo']); + $this->assertArrayNotHasKey('user', $page['props']); + } + + public function testMergePropsFromProvidesInertiaPropertiesAreIncludedInMergePropsMetadata(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'foo' => new MergeProp('foo value'), + ]; + } + }, + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('foo value', $page['props']['foo']); + $this->assertSame(['foo'], $page['mergeProps']); + } + + public function testOncePropsFromProvidesInertiaPropertiesAreIncludedInOncePropsMetadata(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'foo' => Inertia::once(fn () => 'bar'), + ]; + } + }, + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('bar', $page['props']['foo']); + $this->assertSame(['foo' => ['prop' => 'foo', 'expiresAt' => null]], $page['onceProps']); + } + + public function testDeferredMergePropsFromProvidesInertiaPropertiesIncludeBothMetadata(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'foo' => (new DeferProp(fn () => 'foo value', 'default'))->merge(), + ]; + } + }, + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertArrayNotHasKey('foo', $page['props']); + $this->assertSame([ + 'default' => ['foo'], + ], $page['deferredProps']); + $this->assertSame(['foo'], $page['mergeProps']); + } +} diff --git a/tests/Inertia/ScrollMetadataTest.php b/tests/Inertia/ScrollMetadataTest.php new file mode 100644 index 000000000..533a467d8 --- /dev/null +++ b/tests/Inertia/ScrollMetadataTest.php @@ -0,0 +1,152 @@ +simplePaginate(15); + + if ($wrappedinHttpResource) { + $users = UserResource::collection($users); + } + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + ], ScrollMetadata::fromPaginator($users)->toArray()); + + RequestContext::set(Request::create('/?page=2')); + $users = User::query()->simplePaginate(15); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => 1, + 'nextPage' => 3, + 'currentPage' => 2, + ], ScrollMetadata::fromPaginator($users)->toArray()); + + RequestContext::set(Request::create('/?page=3')); + $users = User::query()->simplePaginate(15); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => 2, + 'nextPage' => null, + 'currentPage' => 3, + ], ScrollMetadata::fromPaginator($users)->toArray()); + } + + #[DataProvider('wrappedOrUnwrappedProvider')] + public function testExtractMetadataFromLengthAwarePaginator(bool $wrappedinHttpResource): void + { + $users = User::query()->paginate(15); + + if ($wrappedinHttpResource) { + $users = UserResource::collection($users); + } + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + ], ScrollMetadata::fromPaginator($users)->toArray()); + + RequestContext::set(Request::create('/?page=2')); + $users = User::query()->paginate(15); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => 1, + 'nextPage' => 3, + 'currentPage' => 2, + ], ScrollMetadata::fromPaginator($users)->toArray()); + + RequestContext::set(Request::create('/?page=3')); + $users = User::query()->paginate(15); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => 2, + 'nextPage' => null, + 'currentPage' => 3, + ], ScrollMetadata::fromPaginator($users)->toArray()); + } + + #[DataProvider('wrappedOrUnwrappedProvider')] + public function testExtractMetadataFromCursorPaginator(bool $wrappedinHttpResource): void + { + $users = User::query()->cursorPaginate(15); + + if ($wrappedinHttpResource) { + $users = UserResource::collection($users); + } + + $this->assertEquals([ + 'pageName' => 'cursor', + 'previousPage' => null, + 'nextPage' => $users->nextCursor()?->encode(), + 'currentPage' => 1, + ], $first = ScrollMetadata::fromPaginator($users)->toArray()); + + RequestContext::set(Request::create('/?cursor=' . $first['nextPage'])); + $users = User::query()->cursorPaginate(15); + + $this->assertEquals([ + 'pageName' => 'cursor', + 'previousPage' => $users->previousCursor()?->encode(), + 'nextPage' => $users->nextCursor()?->encode(), + 'currentPage' => $first['nextPage'], + ], $second = ScrollMetadata::fromPaginator($users)->toArray()); + + RequestContext::set(Request::create('/?cursor=' . $second['nextPage'])); + $users = User::query()->cursorPaginate(15); + + $this->assertEquals([ + 'pageName' => 'cursor', + 'previousPage' => $users->previousCursor()?->encode(), + 'nextPage' => null, + 'currentPage' => $second['nextPage'], + ], ScrollMetadata::fromPaginator($users)->toArray()); + } + + /** + * @return array> + */ + public static function wrappedOrUnwrappedProvider(): array + { + return [ + 'wrapped in http resource' => [true], + 'not wrapped in http resource' => [false], + ]; + } + + public function testThrowsExceptionIfNotAPaginator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The given value is not a Hypervel paginator instance. Use a custom callback to extract pagination metadata.'); + + ScrollMetadata::fromPaginator(collect()); + } +} diff --git a/tests/Inertia/ScrollPropTest.php b/tests/Inertia/ScrollPropTest.php new file mode 100644 index 000000000..5bed81e40 --- /dev/null +++ b/tests/Inertia/ScrollPropTest.php @@ -0,0 +1,261 @@ +setUpInteractsWithUserModels(); + } + + public function testResolvesMetaData(): void + { + $users = User::query()->paginate(15); + $scrollProp = new ScrollProp($users); + + $metadata = $scrollProp->metadata(); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + ], $metadata); + } + + public function testResolvesCustomMetaData(): void + { + $users = User::query()->paginate(15); + + $customMetaCallback = fn () => new class implements ProvidesScrollMetadata { + public function getPageName(): string + { + return 'usersPage'; + } + + public function getPreviousPage(): int + { + return 10; + } + + public function getNextPage(): int + { + return 12; + } + + public function getCurrentPage(): int + { + return 11; + } + }; + + $scrollProp = new ScrollProp($users, 'data', $customMetaCallback); + + $metadata = $scrollProp->metadata(); + + $this->assertEquals([ + 'pageName' => 'usersPage', + 'previousPage' => 10, + 'nextPage' => 12, + 'currentPage' => 11, + ], $metadata); + } + + public function testCanSetTheMergeIntentBasedOnTheMergeIntentHeader(): void + { + $users = User::query()->paginate(15); + $request = Request::create('/'); + RequestContext::set($request); + + // Test append intent without header + $appendProp = new ScrollProp($users); + $appendProp->configureMergeIntent(); + $this->assertContains('data', $appendProp->appendsAtPaths()); + $this->assertEmpty($appendProp->prependsAtPaths()); + + // Test append intent with header set to 'append' + $request->headers->set(Header::INFINITE_SCROLL_MERGE_INTENT, 'append'); + $appendProp = new ScrollProp($users); + $appendProp->configureMergeIntent(); + $this->assertContains('data', $appendProp->appendsAtPaths()); + $this->assertEmpty($appendProp->prependsAtPaths()); + + // Test prepend intent + $request->headers->set(Header::INFINITE_SCROLL_MERGE_INTENT, 'prepend'); + $prependProp = new ScrollProp($users); + $prependProp->configureMergeIntent(); + $this->assertContains('data', $prependProp->prependsAtPaths()); + $this->assertEmpty($prependProp->appendsAtPaths()); + + // Test prepend intent with custom wrapper + $request->headers->set(Header::INFINITE_SCROLL_MERGE_INTENT, 'prepend'); + $prependProp = new ScrollProp($users, 'items'); + $prependProp->configureMergeIntent(); + $this->assertContains('items', $prependProp->prependsAtPaths()); + $this->assertEmpty($prependProp->appendsAtPaths()); + } + + public function testResolvesMetaDataWithCallableProvider(): void + { + $callableMetadata = function () { + return new class implements ProvidesScrollMetadata { + public function getPageName(): string + { + return 'callablePage'; + } + + public function getPreviousPage(): int + { + return 5; + } + + public function getNextPage(): int + { + return 7; + } + + public function getCurrentPage(): int + { + return 6; + } + }; + }; + + $scrollProp = new ScrollProp([], 'data', $callableMetadata); + + $metadata = $scrollProp->metadata(); + + $this->assertEquals([ + 'pageName' => 'callablePage', + 'previousPage' => 5, + 'nextPage' => 7, + 'currentPage' => 6, + ], $metadata); + } + + public function testScrollPropValueIsResolvedOnlyOnce(): void + { + $callCount = 0; + + $scrollProp = new ScrollProp(function () use (&$callCount) { + ++$callCount; + + return ['item1', 'item2', 'item3']; + }); + + // Call the scroll prop multiple times + $value1 = $scrollProp(); + $value2 = $scrollProp(); + $value3 = $scrollProp(); + + // Verify the callback was only called once + $this->assertEquals(1, $callCount, 'Scroll prop value callback should only be executed once'); + + // Verify all calls return the same result + $this->assertEquals($value1, $value2); + $this->assertEquals($value2, $value3); + $this->assertEquals(['item1', 'item2', 'item3'], $value1); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $scrollProp = new ScrollProp('date'); + + $this->assertSame('date', $scrollProp()); + } + + public function testDeferredScrollPropIsExcludedFromInitialRequest(): void + { + $request = Request::create('/users', 'GET'); + + $response = new Response( + 'Users/Index', + [], + [ + 'users' => (new ScrollProp(fn () => User::query()->paginate(15)))->defer(), + ], + 'app', + '123' + ); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertArrayNotHasKey('users', $page['props']); + $this->assertSame(['default' => ['users']], $page['deferredProps']); + $this->assertArrayNotHasKey('scrollProps', $page); + } + + public function testDeferredScrollPropIsResolvedOnPartialRequest(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'Users/Index']); + $request->headers->add(['X-Inertia-Partial-Data' => 'users']); + + $response = new Response( + 'Users/Index', + [], + [ + 'users' => (new ScrollProp(fn () => User::query()->paginate(15)))->defer(), + ], + 'app', + '123' + ); + + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertObjectHasProperty('users', $page->props); + $this->assertCount(15, $page->props->users->data); + $this->assertObjectHasProperty('scrollProps', $page); + $this->assertEquals('page', $page->scrollProps->users->pageName); + $this->assertContains('users.data', $page->mergeProps); + } + + public function testDeferredScrollPropCanHaveCustomGroup(): void + { + $request = Request::create('/users', 'GET'); + + $response = new Response( + 'Users/Index', + [], + [ + 'users' => (new ScrollProp(fn () => User::query()->paginate(15)))->defer('custom-group'), + ], + 'app', + '123' + ); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertArrayNotHasKey('users', $page['props']); + $this->assertSame(['custom-group' => ['users']], $page['deferredProps']); + } +} diff --git a/tests/Inertia/SsrRenderFailedTest.php b/tests/Inertia/SsrRenderFailedTest.php new file mode 100644 index 000000000..a277efa98 --- /dev/null +++ b/tests/Inertia/SsrRenderFailedTest.php @@ -0,0 +1,82 @@ + 'Dashboard', 'url' => '/dashboard'], + error: 'Test error', + ); + + $this->assertEquals('Dashboard', $event->component()); + } + + public function testItExtractsUrlFromPage(): void + { + $event = new SsrRenderFailed( + page: ['component' => 'Dashboard', 'url' => '/dashboard'], + error: 'Test error', + ); + + $this->assertEquals('/dashboard', $event->url()); + } + + public function testItStoresSourceLocation(): void + { + $event = new SsrRenderFailed( + page: ['component' => 'Dashboard'], + error: 'window is not defined', + sourceLocation: '/path/to/Dashboard.vue:10:5', + ); + + $this->assertEquals('/path/to/Dashboard.vue:10:5', $event->sourceLocation); + } + + public function testItIncludesSourceLocationInToArray(): void + { + $event = new SsrRenderFailed( + page: ['component' => 'Dashboard', 'url' => '/dashboard'], + error: 'window is not defined', + type: SsrErrorType::BrowserApi, + hint: 'Wrap in lifecycle hook', + browserApi: 'window', + sourceLocation: '/path/to/Dashboard.vue:10:5', + ); + + $array = $event->toArray(); + + $this->assertEquals('/path/to/Dashboard.vue:10:5', $array['source_location']); + $this->assertEquals('Dashboard', $array['component']); + $this->assertEquals('/dashboard', $array['url']); + $this->assertEquals('window is not defined', $array['error']); + $this->assertEquals('browser-api', $array['type']); + $this->assertEquals('Wrap in lifecycle hook', $array['hint']); + $this->assertEquals('window', $array['browser_api']); + } + + public function testToArrayExcludesNullValues(): void + { + $event = new SsrRenderFailed( + page: ['component' => 'Dashboard', 'url' => '/dashboard'], + error: 'Something went wrong', + ); + + $array = $event->toArray(); + + $this->assertArrayNotHasKey('hint', $array); + $this->assertArrayNotHasKey('browser_api', $array); + $this->assertArrayNotHasKey('source_location', $array); + } +} diff --git a/tests/Inertia/TestCase.php b/tests/Inertia/TestCase.php new file mode 100644 index 000000000..1bad6c5a6 --- /dev/null +++ b/tests/Inertia/TestCase.php @@ -0,0 +1,68 @@ + + */ + protected const EXAMPLE_PAGE_OBJECT = [ + 'component' => 'Foo/Bar', + 'props' => ['foo' => 'bar'], + 'url' => '/test', + 'version' => '', + ]; + + protected function getPackageProviders(ApplicationContract $app): array + { + return [ + InertiaServiceProvider::class, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + View::addLocation(__DIR__ . '/Fixtures'); + + Inertia::setRootView('welcome'); + Inertia::transformComponentUsing(); + config()->set('inertia.testing.ensure_pages_exist', false); + config()->set('inertia.pages.paths', [realpath(__DIR__)]); + } + + /** + * Make a mock request through the given middleware. + * + * @param array|class-string $middleware + * @return TestResponse + */ + protected function makeMockRequest(mixed $view, string|array $middleware = []): TestResponse + { + $middleware = is_array($middleware) ? $middleware : [$middleware]; + + app('router')->middleware($middleware)->get('/example-url', function () use ($view) { + return is_callable($view) ? $view() : $view; + }); + + return $this->get('/example-url'); + } +} diff --git a/tests/Inertia/Testing/AssertableInertiaTest.php b/tests/Inertia/Testing/AssertableInertiaTest.php new file mode 100644 index 000000000..be491bead --- /dev/null +++ b/tests/Inertia/Testing/AssertableInertiaTest.php @@ -0,0 +1,516 @@ +makeMockRequest( + Inertia::render('foo') + ); + + $response->assertInertia(); + } + + public function testTheViewIsNotServedByInertia(): void + { + $response = $this->makeMockRequest(view('welcome')); + $response->assertOk(); // Make sure we can render the built-in Orchestra 'welcome' view.. + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Not a valid Inertia response.'); + + $response->assertInertia(); + } + + public function testTheComponentMatches(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $response->assertInertia(function ($inertia) { + $inertia->component('foo'); + }); + } + + public function testTheComponentDoesNotMatch(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected Inertia page component.'); + + $response->assertInertia(function ($inertia) { + $inertia->component('bar'); + }); + } + + public function testTheComponentExistsOnTheFilesystem(): void + { + $response = $this->makeMockRequest( + Inertia::render('Fixtures/ExamplePage') + ); + + config()->set('inertia.testing.ensure_pages_exist', true); + $response->assertInertia(function ($inertia) { + $inertia->component('Fixtures/ExamplePage'); + }); + } + + public function testTheComponentExistsOnTheFilesystemWhenAComponentResolverIsConfigured(): void + { + $calledWith = null; + + Inertia::transformComponentUsing(static function (string $name) use (&$calledWith): string { + $calledWith = $name; + + return "{$name}/Page"; + }); + + $response = $this->makeMockRequest( + Inertia::render('Fixtures/Example') + ); + + config()->set('inertia.testing.ensure_pages_exist', true); + + $response->assertInertia(function ($inertia) { + $inertia->component('Fixtures/Example/Page'); + }); + + $this->assertSame('Fixtures/Example', $calledWith); + } + + public function testTheComponentDoesNotExistOnTheFilesystem(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + config()->set('inertia.testing.ensure_pages_exist', true); + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia page component file [foo] does not exist.'); + + $response->assertInertia(function ($inertia) { + $inertia->component('foo'); + }); + } + + public function testItCanForceEnableTheComponentFileExistence(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + config()->set('inertia.testing.ensure_pages_exist', false); + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia page component file [foo] does not exist.'); + + $response->assertInertia(function ($inertia) { + $inertia->component('foo', true); + }); + } + + public function testItCanForceDisableTheComponentFileExistenceCheck(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + config()->set('inertia.testing.ensure_pages_exist', true); + + $response->assertInertia(function ($inertia) { + $inertia->component('foo', false); + }); + } + + public function testTheComponentDoesNotExistOnTheFilesystemWhenItDoesNotExistRelativeToAnyOfTheGivenPaths(): void + { + $response = $this->makeMockRequest( + Inertia::render('fixtures/ExamplePage') + ); + + config()->set('inertia.testing.ensure_pages_exist', true); + config()->set('inertia.pages.paths', [realpath(__DIR__)]); + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia page component file [fixtures/ExamplePage] does not exist.'); + + $response->assertInertia(function ($inertia) { + $inertia->component('fixtures/ExamplePage'); + }); + } + + public function testTheComponentDoesNotExistOnTheFilesystemWhenItDoesNotHaveOneOfTheConfiguredExtensions(): void + { + $response = $this->makeMockRequest( + Inertia::render('fixtures/ExamplePage') + ); + + config()->set('inertia.testing.ensure_pages_exist', true); + config()->set('inertia.pages.extensions', ['bin', 'exe', 'svg']); + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia page component file [fixtures/ExamplePage] does not exist.'); + + $response->assertInertia(function ($inertia) { + $inertia->component('fixtures/ExamplePage'); + }); + } + + public function testThePageUrlMatches(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $response->assertInertia(function ($inertia) { + $inertia->url('/example-url'); + }); + } + + public function testThePageUrlDoesNotMatch(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected Inertia page url.'); + + $response->assertInertia(function ($inertia) { + $inertia->url('/invalid-page'); + }); + } + + public function testTheAssetVersionMatches(): void + { + Inertia::version('example-version'); + + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $response->assertInertia(function ($inertia) { + $inertia->version('example-version'); + }); + } + + public function testTheAssetVersionDoesNotMatch(): void + { + Inertia::version('example-version'); + + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected Inertia asset version.'); + + $response->assertInertia(function ($inertia) { + $inertia->version('different-version'); + }); + } + + public function testReloadingAVisit(): void + { + $foo = 0; + + $response = $this->makeMockRequest(function () use (&$foo) { + return Inertia::render('foo', [ + 'foo' => $foo++, + ]); + }); + + $called = false; + + $response->assertInertia(function ($inertia) use (&$called) { + $inertia->where('foo', 0); + + $inertia->reload(function ($inertia) use (&$called) { + $inertia->where('foo', 1); + $called = true; + }); + }); + + $this->assertTrue($called); + } + + public function testOptionalPropsCanBeEvaluated(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', [ + 'foo' => 'bar', + 'optional1' => Inertia::optional(fn () => 'baz'), + 'optional2' => Inertia::optional(fn () => 'qux'), + ]) + ); + + $called = false; + + $response->assertInertia(function ($inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('optional1'); + $inertia->missing('optional2'); + + $result = $inertia->reloadOnly('optional1', function ($inertia) use (&$called) { + $inertia->missing('foo'); + $inertia->where('optional1', 'baz'); + $inertia->missing('optional2'); + $called = true; + }); + + $this->assertSame($result, $inertia); + }); + + $this->assertTrue($called); + } + + public function testOptionalPropsCanBeEvaluatedWithExcept(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', [ + 'foo' => 'bar', + 'lazy1' => Inertia::optional(fn () => 'baz'), + 'lazy2' => Inertia::optional(fn () => 'qux'), + ]) + ); + + $called = false; + + $response->assertInertia(function ($inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('lazy1'); + $inertia->missing('lazy2'); + + $result = $inertia->reloadOnly(['lazy1'], function ($inertia) use (&$called) { + $inertia->missing('foo'); + $inertia->where('lazy1', 'baz'); + $inertia->missing('lazy2'); + $called = true; + }); + + $this->assertSame($result, $inertia); + }); + + $this->assertTrue($called); + } + + public function testLazyPropsCanBeEvaluatedWithExcept(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', [ + 'foo' => 'bar', + 'optional1' => Inertia::optional(fn () => 'baz'), + 'optional2' => Inertia::optional(fn () => 'qux'), + ]) + ); + + $called = false; + + $response->assertInertia(function (AssertableInertia $inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('optional1'); + $inertia->missing('optional2'); + + $inertia->reloadExcept('optional1', function ($inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('optional1'); + $inertia->where('optional2', 'qux'); + $called = true; + }); + }); + + $this->assertTrue($called); + } + + public function testLazyPropsCanBeEvaluatedWithExceptWhenExceptIsArray(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', [ + 'foo' => 'bar', + 'lazy1' => Inertia::optional(fn () => 'baz'), + 'lazy2' => Inertia::optional(fn () => 'qux'), + ]) + ); + + $called = false; + + $response->assertInertia(function ($inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('lazy1'); + $inertia->missing('lazy2'); + + $inertia->reloadExcept(['lazy1'], function ($inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('lazy1'); + $inertia->where('lazy2', 'qux'); + $called = true; + }); + }); + + $this->assertTrue($called); + } + + public function testAssertAgainstDeferredProps(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', [ + 'foo' => 'bar', + 'deferred1' => Inertia::defer(fn () => 'baz'), + 'deferred2' => Inertia::defer(fn () => 'qux', 'custom'), + 'deferred3' => Inertia::defer(fn () => 'quux', 'custom'), + ]) + ); + + $called = 0; + + $response->assertInertia(function (AssertableInertia $inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('deferred1'); + $inertia->missing('deferred2'); + $inertia->missing('deferred3'); + + $inertia->loadDeferredProps(function (AssertableInertia $inertia) use (&$called) { + $inertia->where('deferred1', 'baz'); + $inertia->where('deferred2', 'qux'); + $inertia->where('deferred3', 'quux'); + ++$called; + }); + + $inertia->loadDeferredProps('default', function (AssertableInertia $inertia) use (&$called) { + $inertia->where('deferred1', 'baz'); + $inertia->missing('deferred2'); + $inertia->missing('deferred3'); + ++$called; + }); + + $inertia->loadDeferredProps('custom', function (AssertableInertia $inertia) use (&$called) { + $inertia->missing('deferred1'); + $inertia->where('deferred2', 'qux'); + $inertia->where('deferred3', 'quux'); + ++$called; + }); + + $inertia->loadDeferredProps(['default', 'custom'], function (AssertableInertia $inertia) use (&$called) { + $inertia->where('deferred1', 'baz'); + $inertia->where('deferred2', 'qux'); + $inertia->where('deferred3', 'quux'); + ++$called; + }); + }); + + $this->assertSame(4, $called); + } + + public function testTheFlashDataCanBeAsserted(): void + { + $response = $this->makeMockRequest( + fn () => Inertia::render('foo')->flash([ + 'message' => 'Hello World', + 'notification' => ['type' => 'success'], + ]), + StartSession::class + ); + + $response->assertInertia(function (AssertableInertia $inertia) { + $inertia->hasFlash('message'); + $inertia->hasFlash('message', 'Hello World'); + $inertia->hasFlash('notification.type', 'success'); + $inertia->missingFlash('other'); + $inertia->missingFlash('notification.other'); + }); + } + + public function testTheFlashAssertionFailsWhenKeyIsMissing(): void + { + $response = $this->makeMockRequest(Inertia::render('foo')); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia Flash Data is missing key [message].'); + + $response->assertInertia(fn (AssertableInertia $inertia) => $inertia->hasFlash('message')); + } + + public function testTheFlashAssertionFailsWhenValueDoesNotMatch(): void + { + $response = $this->makeMockRequest( + fn () => Inertia::render('foo')->flash('message', 'Hello World'), + StartSession::class + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia Flash Data [message] does not match expected value.'); + + $response->assertInertia(fn (AssertableInertia $inertia) => $inertia->hasFlash('message', 'Different')); + } + + public function testTheMissingFlashAssertionFailsWhenKeyExists(): void + { + $response = $this->makeMockRequest( + fn () => Inertia::render('foo')->flash('message', 'Hello World'), + StartSession::class + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia Flash Data has unexpected key [message].'); + + $response->assertInertia(fn (AssertableInertia $inertia) => $inertia->missingFlash('message')); + } + + public function testTheFlashDataIsAvailableAfterRedirect(): void + { + $middleware = [StartSession::class, Middleware::class]; + + Route::middleware($middleware)->get('/action', function () { + Inertia::flash('message', 'Success!'); + + return redirect('/dashboard'); + }); + + Route::middleware($middleware)->get('/dashboard', function () { + return Inertia::render('Dashboard'); + }); + + $this->get('/action')->assertRedirect('/dashboard'); + $this->get('/dashboard')->assertInertia(fn (AssertableInertia $inertia) => $inertia->hasFlash('message', 'Success!')); + } + + public function testTheFlashDataIsAvailableAfterDoubleRedirect(): void + { + $middleware = [StartSession::class, Middleware::class]; + + Route::middleware($middleware)->get('/action', function () { + Inertia::flash('message', 'Success!'); + + return redirect('/intermediate'); + }); + + Route::middleware($middleware)->get('/intermediate', function () { + return redirect('/dashboard'); + }); + + Route::middleware($middleware)->get('/dashboard', function () { + return Inertia::render('Dashboard'); + }); + + $this->get('/action')->assertRedirect('/intermediate'); + $this->get('/intermediate')->assertRedirect('/dashboard'); + $this->get('/dashboard')->assertInertia(fn (AssertableInertia $inertia) => $inertia->hasFlash('message', 'Success!')); + } +} diff --git a/tests/Inertia/Testing/TestResponseMacrosTest.php b/tests/Inertia/Testing/TestResponseMacrosTest.php new file mode 100644 index 000000000..1c0ab9bbd --- /dev/null +++ b/tests/Inertia/Testing/TestResponseMacrosTest.php @@ -0,0 +1,152 @@ +makeMockRequest( + Inertia::render('foo') + ); + + $success = false; + $response->assertInertia(function ($page) use (&$success) { + $this->assertInstanceOf(AssertableJson::class, $page); + $success = true; + }); + + $this->assertTrue($success); + } + + public function testItPreservesTheAbilityToContinueChainingLaravelTestResponseCalls(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $this->assertInstanceOf( + TestResponse::class, + $response->assertInertia() + ); + } + + public function testItCanRetrieveTheInertiaPage(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', ['bar' => 'baz']) + ); + + tap($response->inertiaPage(), function (array $page) { + $this->assertSame('foo', $page['component']); + $this->assertSame(['bar' => 'baz'], $page['props']); + $this->assertSame('/example-url', $page['url']); + $this->assertSame('', $page['version']); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertArrayNotHasKey('clearHistory', $page); + }); + } + + public function testItCanRetrieveTheInertiaProps(): void + { + $props = ['bar' => 'baz']; + $response = $this->makeMockRequest( + Inertia::render('foo', $props) + ); + + $this->assertSame($props, $response->inertiaProps()); + } + + public function testItCanRetrieveNestedInertiaPropValuesWithDotNotation(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', [ + 'bar' => ['baz' => 'qux'], + 'users' => [ + ['name' => 'John'], + ['name' => 'Jane'], + ], + ]) + ); + + $this->assertSame('qux', $response->inertiaProps('bar.baz')); + $this->assertSame('John', $response->inertiaProps('users.0.name')); + } + + public function testItCanAssertFlashDataOnRedirectResponses(): void + { + $middleware = [StartSession::class, Middleware::class]; + + Route::middleware($middleware)->post('/users', function () { + return Inertia::flash([ + 'message' => 'User created!', + 'notification' => ['type' => 'success'], + ])->back(); + }); + + $this->post('/users') + ->assertRedirect() + ->assertInertiaFlash('message') + ->assertInertiaFlash('message', 'User created!') + ->assertInertiaFlash('notification.type', 'success') + ->assertInertiaFlashMissing('error') + ->assertInertiaFlashMissing('notification.other'); + } + + public function testAssertHasInertiaFlashFailsWhenKeyIsMissing(): void + { + $middleware = [StartSession::class, Middleware::class]; + + Route::middleware($middleware)->post('/users', function () { + return Inertia::flash('message', 'Hello')->back(); + }); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia Flash Data is missing key [other].'); + + $this->post('/users')->assertInertiaFlash('other'); + } + + public function testAssertHasInertiaFlashFailsWhenValueDoesNotMatch(): void + { + $middleware = [StartSession::class, Middleware::class]; + + Route::middleware($middleware)->post('/users', function () { + return Inertia::flash('message', 'Hello')->back(); + }); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia Flash Data [message] does not match expected value.'); + + $this->post('/users')->assertInertiaFlash('message', 'Different'); + } + + public function testAssertMissingInertiaFlashFailsWhenKeyExists(): void + { + $middleware = [StartSession::class, Middleware::class]; + + Route::middleware($middleware)->post('/users', function () { + return Inertia::flash('message', 'Hello')->back(); + }); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia Flash Data has unexpected key [message].'); + + $this->post('/users')->assertInertiaFlashMissing('message'); + } +}