diff --git a/docs/todo.md b/docs/todo.md index 1e5f920704..8a479ddf4b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -30,6 +30,8 @@ ## Testing +- Add the required title docblocks to the untitled non-test methods in `tests/`: about 8,400 methods in 1,600 files, mostly helpers, fixtures, and methods of anonymous classes in older tests. `AGENTS.md` requires a title on every method except `test*` methods, but most existing tests omit them, and agents follow the surrounding code over the written rule, so new tests keep reproducing the gap until review catches it. Make the change as one approved sweep (short imperative titles only), then add an automated check, such as a PHPStan rule, so the code and the rule cannot drift apart again. Decide whether the check also covers `src/`. +- Add native return types to the `test*` methods that lack them: about 5,600 methods in 490 files. `AGENTS.md` requires `: void` on test methods; a test that returns a value for `#[Depends]` takes its actual return type instead. As with method titles, untyped methods in existing files lead new tests to copy them, so make the change as one approved sweep and enforce it with the same automated check. - Integrate the resolution of [PHPUnit #7039](https://github.com/sebastianbergmann/phpunit/issues/7039) into `RunTestsInCoroutine` once available; [PR #7040](https://github.com/sebastianbergmann/phpunit/pull/7040) proposes suspend/resume output buffering. Follow the accepted upstream design, including calling `parent::invokeTestMethod()` if capture is handled there. Add regression coverage for output expectations, flushed output and buffer cleanup; update the testing guidance and reassess output-related coroutine opt-outs. - Lift the PHPUnit `13.3.*` pin in the root, Testbench and dogfood package manifests, and in the `hypervel/hypervel` application skeleton, once ParaTest supports PHPUnit 13.4. PHPUnit 13.4.0 made the internal `PhpHandler` constructor require an event emitter, and ParaTest 7.25.0 and Hypervel's `RunsInParallel` still construct it without one, so parallel runs fail before any test starts. When lifting the pin, pass the emitter to `PhpHandler` in `RunsInParallel`, as PHPUnit 13.4's own `Application` does. diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index 17b985b61a..79a1be4159 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -107,9 +107,10 @@ laravel/scout: laravel/slack-notification-channel: branch: 3.x - checked_through: null + checked_through: 7b7c3e220a4f7a45cd88d65069f28d4fb4b67281 last_reviewed_pr: null - sync_date: null + sync_date: '2026-10-08' + notes: Upstream src/ maps to src/slack-notification-channel/src and tests/ to tests/SlackNotificationChannel; the two tests/Slack notification fixtures live in Slack/Fixtures, and the feature tests' sendNotification and assertNotificationSent helpers live in Slack/TestCase. User documentation is the Slack Notifications section of src/docs/notifications.md, which also documents the webhook routes Laravel's docs omit. The Web API channel sends through the slack-notifications HTTP connection (SlackChannel::CONNECTION), so keep connection() when porting request changes. Feature tests fake Slack's API responses instead of calling Slack, and router tests register Mockery channel instances because determineChannel() returns typed channels. Horizon's LongWaitDetected builds both message types; run its test after message API changes. laravel/telescope: branch: 5.x @@ -228,6 +229,50 @@ Sammyjo20/saloon-docs: sync_date: '2026-10-07' notes: Shared documentation for the unified src/saloon package and all four plugin entries above. Reconcile applicable core and plugin pages into the existing sections of the single src/docs/saloon.md rather than separate plugin guides, keeping Hypervel's additions and checking examples against current source, in Laravel-style prose matching the guide. The v3 branch includes v4 guidance. Installation commands, supported versions, upgrade guides, release notes, showcase, tutorials, the book and the third-party Lawman and SDK generator pages have no Hypervel equivalent; check them only for behavior the guide must still describe. +hyperf/engine: + branch: master + # Owner-selected history boundary before 2025-11-01, not a historical parity claim. + checked_through: 6595d2659ce7ebb940b8740a00ecd199128398f0 + last_reviewed_pr: null + sync_date: null + notes: >- + Selective runtime tracking for Hypervel's independently maintained Engine; + review before hyperf/hyperf. Source maps to src/engine and interfaces to + src/contracts/src/Engine. Consult hyperf/engine-contract when an Engine change + depends on its contracts; it is not a separate tracking entry. Assess changes + for applicable correctness, security, coroutine isolation, blocking, + cancellation, resource cleanup, pooling, Swoole compatibility and performance + improvements. Verify applicability against current Hypervel source, adapt + useful changes and include relevant regression coverage. Preserve Hypervel's + architecture, Laravel-style APIs and enhancements; do not pursue Hyperf API + or full test-suite parity, restore removed Hyperf infrastructure or add Swow + support. HTTP server and response-emitter changes must be assessed against + src/http-server's Swoole bridges rather than restoring Engine's portability + layer. Follow affected consumers beyond these mappings when necessary. + +hyperf/hyperf: + branch: master + # Owner-selected history boundary before 2025-11-01, not a historical parity claim. + checked_through: ded8b966ecf78590e8201a47a56c635f8df99e47 + last_reviewed_pr: null + sync_date: null + notes: >- + Selective runtime tracking under the same applicability rules as hyperf/engine + above, not a framework merge target. Review the monorepo, not its subtree-split + mirrors. Upstream src/{context,coroutine,coordinator,server,signal,websocket-server,watcher} + map to the same Hypervel packages; pool maps to connection-pool, with shared + pooling issues also assessed against object-pool; framework bootstrap/events + and logging map to core; process maps to server-process; http-server and + http-message transport/lifecycle changes map to http-server's bridges; di AOP + and proxy generation map to di, not Hyperf's container; contract maps to the + corresponding runtime interfaces in contracts. Assess db-connection and + database driver changes against database's connection/pooling code, redis + against redis's connection/pooling code, grpc/grpc-client/grpc-server and + http2-client against grpc and Engine HTTP/2, and guzzle transport issues against + http and engine's different transports. These are applicability mappings, + not instructions to restore upstream implementations. Laravel remains the + primary API and implementation reference for Laravel-ported components. + getsentry/sentry-laravel: branch: master checked_through: null diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index 40d4234808..13f9a2940e 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -232,6 +232,8 @@ The `s3` and `gcs` drivers pool their SDK clients by default. The bucket-specifi Pool identity is derived from the exact normalized configuration passed to the SDK client constructor. Equivalent client configurations converge automatically, including repeated `Storage::build()` calls. These configurations must use the same pool options; a mismatch throws immediately instead of silently reusing the first configuration's settings. Different credentials, regions, endpoints, or client options produce different pools. +The `ftp` and `sftp` drivers keep an open connection inside each disk, so Hypervel pools these disks whole. Each operation borrows a disk with its own connection, so concurrent requests never share one. + You may configure a pool using the disk's `pool` option: ```php @@ -249,7 +251,7 @@ You may configure a pool using the disk's `pool` option: ], ``` -`min_retained_objects` is an idle-trimming floor; it does not eagerly create clients. `max_lifetime` expires clients by absolute age, while `max_idle_time` trims individual idle clients. `pool_idle_timeout` removes an entirely unused pool after 300 seconds by default. Set any of these three optional durations to `null` to disable it. If all clients are in use and no capacity becomes available before `wait_timeout`, a `RuntimeException` is thrown. +`min_retained_objects` is an idle-trimming floor; it does not eagerly create clients. `max_lifetime` expires clients by absolute age, while `max_idle_time` trims individual idle clients. `pool_idle_timeout` removes an entirely unused pool after 300 seconds by default. Set any of these three optional durations to `null` to disable it. If all clients are in use and no capacity becomes available before `wait_timeout`, a `RuntimeException` is thrown. Each worker has its own pools, so an FTP or SFTP disk may open up to `max_objects` connections in every worker; keep that total within your server's connection limit. An explicit pool name may be useful when multiple configurations intentionally identify the same operational resource: @@ -285,7 +287,7 @@ $result = Storage::disk('s3')->withClient(function ($client) { }); ``` -`Storage::forgetDisk()` only removes the manager's cached disk wrapper; an equivalent wrapper can continue using the shared pool. `Storage::purge()` removes the wrapper and closes its current pool, deriving the same pool identity even when the named disk has not been resolved yet or is composed from nested scoped disks. Other disks converging on that pool transparently create a fresh one on their next operation. Streams returned by `readStream()` or `readStreamRange()` retain their client lease until the stream is closed or destroyed. +`Storage::forgetDisk()` only removes the manager's cached disk wrapper; an equivalent wrapper can continue using the shared pool. `Storage::purge()` removes the wrapper and closes its current pool, deriving the same pool identity even when the named disk has not been resolved yet or is composed from nested scoped disks. Other disks converging on that pool transparently create a fresh one on their next operation. A stream returned by `readStream()` or `readStreamRange()` that still reads from its connection keeps that connection out of the pool until the stream is closed or destroyed. Fully buffered reads, including FTP and SFTP downloads, return the connection to the pool before you consume the stream. S3 and Google Cloud Storage streams are read lazily by default, which keeps memory usage bounded and makes data available before the entire file has downloaded. This applies to `readStream()` and `readStreamRange()`; methods such as `get()` retain their normal behavior. Streaming requests close their HTTP connection after the read, so applications that open many small streams may prefer connection reuse and set the disk's `stream_reads` option to `false`. @@ -783,7 +785,7 @@ Storage::disk('local')->moveToDisk( ); ``` -Transfers from pooled disks, including S3 and Google Cloud Storage, buffer the source before writing to the destination so the source's pool slot is available during the write. Buffering keeps up to 2 MB in memory per transfer, then uses PHP's system temporary directory; allow enough temporary disk space for large files and concurrent transfers. Local sources stream directly. +Transfers from pooled disks, including S3 and Google Cloud Storage, buffer live source streams before writing to the destination so the source's pool slot is available during the write. Already buffered sources, including FTP and SFTP downloads, are reused without another copy. Buffering keeps up to 2 MB in memory per transfer, then uses PHP's system temporary directory; allow enough temporary disk space for large files and concurrent transfers. Local sources stream directly. ### Automatic Streaming diff --git a/src/docs/frontend.md b/src/docs/frontend.md index dd625d179e..ed4c7c6548 100644 --- a/src/docs/frontend.md +++ b/src/docs/frontend.md @@ -143,6 +143,70 @@ Your local environment is always allowed, so a gate can never lock you out of De The gate only controls who may view entries. Requests are recorded no matter who makes them, so only enable the recorder outside your local environment when untrusted visitors can't reach the application. +#### Previous URL + +Hypervel's session middleware doesn't store the previous URL and route for Inertia visits, since they are sent as AJAX requests, so `session()->previousUrl()`, `session()->previousUri()`, and `session()->previousRoute()` return the last full page load. The `back()` helper is unaffected, as it resolves the previous location from the request's `Referer` header. You may enable the `store_previous_url` option in your application's `config/inertia.php` configuration file to store the previous location for Inertia visits as well: + +```php +'store_previous_url' => true, +``` + +Prefetch requests and [partial reloads](https://inertiajs.com/docs/partial-reloads) are excluded, since deferred props, polling, and infinite scroll requests aren't navigations the user came from. You may customize which visits are stored by overriding the `shouldStoreCurrentUrl` method in your `HandleInertiaRequests` middleware: + +```php +use Hypervel\Http\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * Determine if the visit should be stored as the previous location. + */ +public function shouldStoreCurrentUrl(Request $request, Response $response): bool +{ + return parent::shouldStoreCurrentUrl($request, $response) + && ! $request->routeIs('admin.*'); +} +``` + +#### Big Integers + +JavaScript rounds integers outside its safe range while parsing JSON, so an ID such as `900719925474099988` from a 64-bit database column reaches your components as `900719925474100000`. Inertia can keep these values exact by delivering them as native `BigInt` values. This requires the Inertia client-side adapter at `^3.8`, with nothing to configure on the client. + +Big integer support is disabled by default. You may enable it for every response using the `preserve_big_integers` option in your application's `config/inertia.php` configuration file, or the `INERTIA_PRESERVE_BIG_INTEGERS` environment variable: + +```ini +INERTIA_PRESERVE_BIG_INTEGERS=true +``` + +You may also enable it for a single response using the `preserveBigIntegers` method: + +```php +return Inertia::render('orders/show', [ + 'order' => $order, +])->preserveBigIntegers(); +``` + +Passing `false` opts a single response out when the option is enabled: + +```php +return Inertia::render('reports/index', $props)->preserveBigIntegers(false); +``` + +Only integers outside the safe range become a `BigInt`, so the same prop may arrive as a number or a `BigInt`, depending on its value. Flash data receives the same treatment as props. Arrays, models, collections, API resources, `JsonSerializable` values, and the public properties of your own classes are inspected for large integers, while built-in PHP classes such as `DateTime` are left untouched. + +When enabled, Inertia reserves objects with a string `$bigint` property as integer markers. Avoid that shape in your own props and flash data, since the client converts the entire object to a `BigInt`. + +A `BigInt` submitted through the router, a form, or Precognition is sent as its digits, so your controller receives a numeric string that the `integer` validation rule and the request's `integer` method handle as usual. The `useHttp` hook does not convert `BigInt` values, so convert them to strings before sending them. + +Inertia's testing helpers turn big integers back into integers, so you may assert against the same values you passed to the response: + +```php +use Hypervel\Inertia\Testing\AssertableInertia; + +$response->assertInertia(fn (AssertableInertia $page) => $page + ->where('order.id', 900719925474099988) +); +``` + ### Starter Kits diff --git a/src/docs/notifications.md b/src/docs/notifications.md index 800f123080..2f08e71617 100644 --- a/src/docs/notifications.md +++ b/src/docs/notifications.md @@ -39,6 +39,7 @@ - [Slack Interactivity](#slack-interactivity) - [Routing Slack Notifications](#routing-slack-notifications) - [Notifying External Slack Workspaces](#notifying-external-slack-workspaces) + - [Incoming Webhooks](#slack-incoming-webhooks) - [Localizing Notifications](#localizing-notifications) - [Testing](#testing) - [Notification Events](#notification-events) @@ -1369,13 +1370,15 @@ class User extends Authenticatable ### Prerequisites -Before sending Slack notifications, install the Slack notification channel using Composer: +Before sending Slack notifications, you should install the Slack notification channel via Composer: ```shell composer require hypervel/slack-notification-channel ``` -Next, create a [Slack App](https://api.slack.com/apps?new_app=1) for your Slack workspace. +Additionally, to send notifications to Slack, you must create a [Slack App](https://api.slack.com/apps?new_app=1) for your Slack workspace. + +The scopes and token described below are used to send notifications through Slack's Web API. If you only send notifications to [incoming webhooks](#slack-incoming-webhooks), you do not need them. If you only need to send notifications to the same Slack workspace that the App is created in, you should ensure that your App has the `chat:write`, `chat:write.public`, and `chat:write.customize` scopes. These scopes can be added from the "OAuth & Permissions" App management tab within Slack. @@ -1398,7 +1401,7 @@ If your application will be sending notifications to external Slack workspaces t #### HTTP Client -Slack API notifications use Hypervel's [HTTP client](/docs/{{version}}/http-client) and its `slack-notifications` connection. The connection is registered automatically and uses the HTTP client's default connection timeout of 10 seconds and request timeout of 30 seconds. You may customize its options in your application's `AppServiceProvider`: +Slack Web API notifications use Hypervel's [HTTP client](/docs/{{version}}/http-client) and its `slack-notifications` connection. The connection is registered automatically and uses the HTTP client's default connection timeout of 10 seconds and request timeout of 30 seconds. You may customize its options in your application's `AppServiceProvider`: ```php use Hypervel\Http\Client\Factory; @@ -1588,11 +1591,13 @@ return (new SlackMessage) ### Routing Slack Notifications -To direct Slack notifications to the appropriate Slack team and channel, define a `routeNotificationForSlack` method on your notifiable model. This method can return one of three values: +To direct Slack notifications to the appropriate Slack team and channel, define a `routeNotificationForSlack` method on your notifiable model. This method can return one of the following values: - `null` - which defers routing to the channel configured in the notification itself. You may use the `to` method when building your `SlackMessage` to configure the channel within the notification. - A string specifying the Slack channel to send the notification to, e.g. `#support-channel`. - A `SlackRoute` instance, which allows you to specify an OAuth token and channel name, e.g. `SlackRoute::make($this->slack_channel, $this->slack_token)`. This method should be used to send notifications to external workspaces. +- A webhook URL, as a string or `Psr\Http\Message\UriInterface` instance, which sends the notification to an [incoming webhook](#slack-incoming-webhooks). +- `false` - which skips sending the notification to Slack. For instance, returning `#support-channel` from the `routeNotificationForSlack` method will send the notification to the `#support-channel` channel in the workspace associated with the Bot User OAuth token located in your application's `services.php` configuration file: @@ -1653,6 +1658,51 @@ class User extends Authenticatable } ``` + +### Incoming Webhooks + +Instead of using Slack's Web API, you may send notifications to an [incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks). After enabling incoming webhooks for your Slack App, Slack will provide a webhook URL for the channel you choose. Return this URL from your notifiable model's `routeNotificationForSlack` method. Since the URL may be stored on the model, each user or team can receive notifications through their own webhook: + +```php +use Hypervel\Notifications\Notification; + +/** + * Route notifications for the Slack channel. + */ +public function routeNotificationForSlack(Notification $notification): mixed +{ + return $this->slack_webhook_url; +} +``` + +Slack's incoming webhooks accept the Block Kit messages described above. For Slack-compatible services such as [Mattermost](https://docs.mattermost.com/integrations-guide/faq), use Slack's attachment format by returning a `Hypervel\Notifications\Messages\SlackMessage` instance from your notification's `toSlack` method: + +```php +use Hypervel\Notifications\Messages\SlackAttachment; +use Hypervel\Notifications\Messages\SlackMessage; + +/** + * Get the Slack representation of the notification. + */ +public function toSlack(object $notifiable): SlackMessage +{ + $url = url('/invoices/'.$this->invoice->id); + + return (new SlackMessage) + ->success() + ->content('One of your invoices has been paid!') + ->attachment(function (SlackAttachment $attachment) use ($url) { + $attachment->title('Invoice 1322', $url) + ->fields([ + 'Title' => 'Server Expenses', + 'Amount' => '$1,234', + ]); + }); +} +``` + +Webhook requests use a connection timeout of 10 seconds and a request timeout of 30 seconds. Attachment messages may override these and other [Guzzle request options](https://docs.guzzlephp.org/en/stable/request-options.html) using the `http` method, such as `->http(['timeout' => 10])`. + ## Localizing Notifications diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index a6822fffad..571ad678cd 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -658,6 +658,8 @@ Hypervel's `Filesystem::hash()` method uses `xxh128` by default. Pass `md5` expl Custom filesystem contract implementations must also provide `fileExists()` and `directoryExists()`. The existing `exists()` method continues to accept either a file or a directory. See [retrieving files](/docs/{{version}}/filesystem#retrieving-files). +S3, Google Cloud Storage, FTP, SFTP, and poolable custom disks are pooled, so they do not hand out objects that outlive an operation. Replace `getAdapter()` and `getDriver()` with `withAdapter()` and `withDriver()`, and an S3 or Google Cloud Storage disk's `getClient()` with `withClient()`; each callback receives the borrowed object. Methods a pooled disk does not support throw instead of forwarding to the driver. See [driver pools](/docs/{{version}}/filesystem#driver-pools). + Unlike Laravel, Hypervel honors `read-only` on scoped disk records. Remove that option from any scoped disk that must accept writes. Rename any configured disk called `ondemand`; Hypervel reserves that name for [on-demand disk fakes](/docs/{{version}}/filesystem#on-demand-disks). diff --git a/src/docs/vite.md b/src/docs/vite.md index 9389998fb5..fa04757e0f 100644 --- a/src/docs/vite.md +++ b/src/docs/vite.md @@ -891,6 +891,12 @@ php artisan inertia:start-ssr --runtime=bun You may also configure the runtime using the `INERTIA_SSR_RUNTIME` environment variable. Runtime values may be executable names or absolute paths. +To stop the SSR server, such as when deploying a new version of your application, you may use the `inertia:stop-ssr` command. Your process monitor, such as Supervisor, is responsible for restarting the SSR server after it stops. By default, the command fails when the SSR server is not running. You may pass the `--graceful` option to return a successful exit code instead, which is useful in deployment scripts that may run before the SSR server has started: + +```shell +php artisan inertia:stop-ssr --graceful +``` + The `hot_url` option within your application's `inertia.ssr` configuration may be used to specify the SSR server URL while Vite is running. This option may also be configured using the `INERTIA_SSR_HOT_URL` environment variable. The `connect_timeout` and `timeout` options control how many seconds Hypervel waits for the SSR server; you may set either option to `null` to use the HTTP client's global timeout instead. The `backoff` option determines how long a worker skips SSR after a connection failure or malformed response. For more control, you may use the `Inertia::configureSsrRequestUsing` method, typically from a service provider. The closure receives the `PendingRequest` before it is sent, so you may add retries, headers, or any other option supported by Hypervel's [HTTP client](/docs/{{version}}/http-client): diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 7b32cd144d..47746c4f1d 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -15,7 +15,7 @@ Custom implementations of `Hypervel\Contracts\Filesystem\Filesystem` must provid The configured disk name `ondemand` is reserved. Disks explicitly set under that name, including fakes, intercept anonymous `build()` calls; configured disks with that name are rejected on resolution. -Hypervel pools S3 and Google Cloud Storage SDK clients rather than complete disk adapters. Disks with equivalent client construction config share the expensive client pool while retaining their own bucket, root, visibility, and callback behavior. Pooled disks expose raw internals only through borrow-scoped `withClient()`, `withDriver()`, and `withAdapter()` callbacks. +Hypervel pools S3 and Google Cloud Storage SDK clients rather than complete disk adapters. Disks with equivalent client construction config share the expensive client pool while retaining their own bucket, root, visibility, and callback behavior. FTP and SFTP disks are pooled whole, because each adapter holds one connection that concurrent requests must not share. Pooled disks expose raw internals only through borrow-scoped `withClient()`, `withDriver()`, and `withAdapter()` callbacks. On read-through disks, raw `listContents()` results from pooled, S3 and Google Cloud Storage sides are fully loaded instead of streamed. diff --git a/src/filesystem/src/ClientPooledFilesystem.php b/src/filesystem/src/ClientPooledFilesystem.php index d24ac330ee..737512a63e 100644 --- a/src/filesystem/src/ClientPooledFilesystem.php +++ b/src/filesystem/src/ClientPooledFilesystem.php @@ -147,7 +147,7 @@ protected function buildStack(object $client): FilesystemAdapter } /** - * Open a stream that owns its client lease until closure. + * Open a stream that retains its client lease while needed. * * @param Closure(FilesystemAdapter): mixed $operation * @return null|resource @@ -162,13 +162,7 @@ protected function leasedStream(Closure $operation): mixed $lease->releaseAfterFailure($operationException); } - if (! is_resource($stream)) { - $lease->release(); - - return $stream; - } - - return LeasedStream::wrap($stream, $lease); + return $this->releaseOrWrapStream($stream, $lease); } /** diff --git a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php index 6c77c80675..7fcc69ad1a 100644 --- a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php +++ b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php @@ -11,13 +11,16 @@ use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract; use Hypervel\Filesystem\AwsS3V3Adapter; use Hypervel\Filesystem\FileResponseBuilder; +use Hypervel\Filesystem\FilesystemAdapter; use Hypervel\Filesystem\FilesystemOperatorAdapter; use Hypervel\Filesystem\GoogleCloudStorageAdapter; +use Hypervel\Filesystem\LeasedStream; use Hypervel\Http\File; use Hypervel\Http\Request; use Hypervel\Http\UploadedFile; use Hypervel\Image\Image; use Hypervel\Image\ImageException; +use Hypervel\ObjectPool\Lease; use Hypervel\Support\Traits\Conditionable; use League\Flysystem\FilesystemOperator; use Psr\Http\Message\StreamInterface; @@ -624,12 +627,42 @@ public function buildTemporaryUploadUrlsUsing(?Closure $callback): void abstract protected function invoke(string $method, array $parameters): mixed; /** - * Open a stream that retains its lease until closure. + * Open a stream that retains any required lease until closure. * * @return null|resource */ abstract protected function leasedStream(Closure $operation): mixed; + /** + * Release buffered reads immediately and retain the lease for live streams. + * + * @param null|resource $stream + * @return null|resource + */ + protected function releaseOrWrapStream(mixed $stream, Lease $lease): mixed + { + if (! is_resource($stream)) { + $lease->release(); + + return $stream; + } + + $metadata = stream_get_meta_data($stream); + + // Range wrappers are marked at construction; other decorators can report + // a temporary buffer while still reading from a live connection. + if (in_array($metadata['stream_type'], ['TEMP', 'MEMORY'], true) + || ($metadata['stream_type'] === 'user-space' + && (stream_context_get_options($stream)['hypervel'][FilesystemAdapter::BUFFERED_STREAM_CONTEXT_OPTION] ?? false) === true) + ) { + $lease->release(); + + return $stream; + } + + return LeasedStream::wrap($stream, $lease); + } + /** * Run a callback with an accessor result from a borrowed filesystem. */ diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index 3351b0372f..079fc1f9ba 100644 --- a/src/filesystem/src/FilesystemAdapter.php +++ b/src/filesystem/src/FilesystemAdapter.php @@ -67,6 +67,13 @@ class FilesystemAdapter implements CloudFilesystemContract __call as macroCall; } + /** + * The stream context option identifying a range over an independent buffer. + * + * @internal + */ + public const string BUFFERED_STREAM_CONTEXT_OPTION = 'buffered'; + /** * The filesystem configuration. */ @@ -735,8 +742,14 @@ public function readStreamRange(string $path, ?int $start, ?int $end): mixed $psrStream = Utils::streamFor($stream); $limitedStream = new LimitStream($psrStream, $length, $psrStream->tell()); + $range = StreamWrapper::getResource($limitedStream); + + if (in_array(stream_get_meta_data($stream)['stream_type'], ['TEMP', 'MEMORY'], true)) { + // Only mark this new wrapper; a driver's stream may share the default context. + stream_context_set_option($range, 'hypervel', self::BUFFERED_STREAM_CONTEXT_OPTION, true); + } - return StreamWrapper::getResource($limitedStream); + return $range; } catch (CanceledException $exception) { if (is_resource($stream)) { fclose($stream); diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index 6faf4d01d4..bce535685a 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -105,7 +105,7 @@ class FilesystemManager implements FactoryContract /** * The array of drivers which will be wrapped as pool proxies. */ - protected array $poolableDrivers = ['s3', 'gcs']; + protected array $poolableDrivers = ['s3', 'gcs', 'ftp', 'sftp']; /** * Create a new filesystem manager instance. diff --git a/src/filesystem/src/FilesystemPoolProxy.php b/src/filesystem/src/FilesystemPoolProxy.php index ac0e4af125..11ad4945c3 100644 --- a/src/filesystem/src/FilesystemPoolProxy.php +++ b/src/filesystem/src/FilesystemPoolProxy.php @@ -62,7 +62,7 @@ protected function configureBorrowed(object $object): void } /** - * Open a stream that owns its whole-driver lease until closure. + * Open a stream that retains its whole-driver lease while needed. * * @param Closure(FilesystemContract): mixed $operation * @return null|resource @@ -78,13 +78,7 @@ protected function leasedStream(Closure $operation): mixed $lease->releaseAfterFailure($operationException); } - if (! is_resource($stream)) { - $lease->release(); - - return $stream; - } - - return LeasedStream::wrap($stream, $lease); + return $this->releaseOrWrapStream($stream, $lease); } /** diff --git a/src/fortify/src/FortifyServiceProvider.php b/src/fortify/src/FortifyServiceProvider.php index b793377c0b..e675e7aa5b 100644 --- a/src/fortify/src/FortifyServiceProvider.php +++ b/src/fortify/src/FortifyServiceProvider.php @@ -118,19 +118,19 @@ protected function configurePasskeys(): void { Passkeys::ignoreRoutes(); - $config = $this->app->make(Config::class); - - /** @var null|string $appUrl */ - $appUrl = $config->get('app.url'); - $defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); - $defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; - - $config->set([ - 'passkeys.relying_party_id' => $config->get('fortify.passkeys.relying_party_id', $defaultRelyingPartyId), - 'passkeys.allowed_origins' => $config->get('fortify.passkeys.allowed_origins', $defaultAllowedOrigins), - 'passkeys.user_handle_secret' => $config->get('fortify.passkeys.user_handle_secret', $config->get('app.key')), - 'passkeys.timeout' => $config->integer('fortify.passkeys.timeout', Passkeys::DEFAULT_TIMEOUT), - ]); + $this->configureUsing(static function (Config $config): void { + /** @var null|string $appUrl */ + $appUrl = $config->get('app.url'); + $defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST); + $defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl]; + + $config->set([ + 'passkeys.relying_party_id' => $config->get('fortify.passkeys.relying_party_id', $defaultRelyingPartyId), + 'passkeys.allowed_origins' => $config->get('fortify.passkeys.allowed_origins', $defaultAllowedOrigins), + 'passkeys.user_handle_secret' => $config->get('fortify.passkeys.user_handle_secret', $config->get('app.key')), + 'passkeys.timeout' => $config->integer('fortify.passkeys.timeout', Passkeys::DEFAULT_TIMEOUT), + ]); + }); Passkeys::redirectUsing( static fn (Request $request): string => Fortify::redirects('login', request: $request), diff --git a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php index 3fb3ec007d..2d8c0e40a3 100644 --- a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php +++ b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php @@ -10,6 +10,10 @@ use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\Eloquent\Model; use Hypervel\Engine\Coroutine as EngineCoroutine; +use PHPUnit\Framework\AssertionFailedError; +use PHPUnit\Framework\Attributes\PostCondition; +use PHPUnit\Framework\IncompleteTest; +use PHPUnit\Framework\SkippedTest; use SebastianBergmann\Invoker\TimeoutException; use Swoole\Coroutine; use Swoole\Coroutine\Channel; @@ -24,6 +28,11 @@ trait RunTestsInCoroutine protected bool $copyNonCoroutineContext = true; + /** + * The exception the test threw before its child coroutines reached the time limit. + */ + protected ?Throwable $exceptionOutlivedByChildCoroutines = null; + /** * Invoke the test method inside a Swoole coroutine container. * @@ -43,6 +52,8 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m $testResult = null; $exception = null; $timeoutException = null; + $rootCompleted = false; + $this->exceptionOutlivedByChildCoroutines = null; $capture = static function (callable $callback) use (&$exception): void { try { @@ -52,11 +63,11 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m } }; - run(function () use (&$testResult, &$exception, &$timeoutException, $capture, $methodName, $testArguments): void { + run(function () use (&$testResult, &$exception, &$timeoutException, &$rootCompleted, $capture, $methodName, $testArguments): void { $shouldBootFramework = false; try { - $this->enforceCoroutineTestTimeLimit($timeoutException); + $this->enforceCoroutineTestTimeLimit($timeoutException, $rootCompleted); $this->clearNonCoroutineTransactionContext(); if ($this->copyNonCoroutineContext) { @@ -86,6 +97,19 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m }); if ($timeoutException !== null) { + // Children outliving a test that threw must not hide its exception. PHPUnit still matches it against + // the test's expected exception, and a matched one fails in the post-condition instead. Skipped and + // incomplete tests never reach the post-condition, so they keep the timeout. + if ($rootCompleted + && $exception !== null + && ! $exception instanceof SkippedTest + && ! $exception instanceof IncompleteTest + ) { + $this->exceptionOutlivedByChildCoroutines = $exception; + + throw $exception; + } + throw $timeoutException; } @@ -99,7 +123,7 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m /** * Enforce PHPUnit's deadline while the test or its children are suspended. */ - protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutException): void + protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutException, bool &$rootCompleted): void { if (! function_exists('pcntl_alarm')) { return; @@ -122,7 +146,7 @@ protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutExce $completed->push(true); }); - Coroutine::create(static function () use ($root, $deadline, $completed, &$timeoutException): void { + Coroutine::create(static function () use ($root, $deadline, $completed, &$timeoutException, &$rootCompleted): void { $current = Coroutine::getCid(); try { @@ -130,6 +154,8 @@ protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutExce // Native channel and sleep timers survive Timer::clearAll() cleanup. if ($remaining > 0 && $completed->pop($remaining)) { + $rootCompleted = true; + while (true) { $coroutines = array_filter( iterator_to_array(Coroutine::list()), @@ -168,6 +194,21 @@ protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutExce }); } + /** + * Fail a test whose expected exception was followed by child coroutines reaching the time limit. + */ + #[PostCondition] + protected function assertChildCoroutinesFinishedBeforeTimeLimit(): void + { + if ($this->exceptionOutlivedByChildCoroutines !== null) { + throw new AssertionFailedError( + 'Child coroutines were still running at the time limit after the test threw an expected exception.', + 0, + $this->exceptionOutlivedByChildCoroutines, + ); + } + } + /** * Determine whether tests run in a coroutine. */ diff --git a/src/horizon/src/HorizonServiceProvider.php b/src/horizon/src/HorizonServiceProvider.php index 019d35aaa6..96811305e1 100644 --- a/src/horizon/src/HorizonServiceProvider.php +++ b/src/horizon/src/HorizonServiceProvider.php @@ -4,6 +4,7 @@ namespace Hypervel\Horizon; +use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Redis\Factory as RedisFactory; use Hypervel\Horizon\Connectors\RedisConnector; @@ -40,11 +41,11 @@ public function boot(): void */ protected function normalizeConfig(): void { - $config = $this->app->make('config'); - - if (($name = $config->get('horizon.name')) === null || $name === '') { - $config->set('horizon.name', $config->string('app.name')); - } + $this->configureUsing(static function (Repository $config): void { + if (($name = $config->get('horizon.name')) === null || $name === '') { + $config->set('horizon.name', $config->string('app.name')); + } + }); } /** @@ -165,7 +166,9 @@ protected function configure(): void 'horizon' ); - Horizon::use(config()->string('horizon.use')); + $this->configureUsing(static function (Repository $config): void { + Horizon::use($config->string('horizon.use')); + }); } /** diff --git a/src/horizon/src/Notifications/LongWaitDetected.php b/src/horizon/src/Notifications/LongWaitDetected.php index da1c5fe26c..cbd9ec0736 100644 --- a/src/horizon/src/Notifications/LongWaitDetected.php +++ b/src/horizon/src/Notifications/LongWaitDetected.php @@ -8,6 +8,7 @@ use Hypervel\Horizon\Contracts\LongWaitDetectedNotification; use Hypervel\Horizon\Horizon; use Hypervel\Notifications\Messages\MailMessage; +use Hypervel\Notifications\Messages\SlackAttachment; use Hypervel\Notifications\Messages\SlackMessage; use Hypervel\Notifications\Notification; use Hypervel\Notifications\Slack\BlockKit\Blocks\SectionBlock; @@ -94,7 +95,7 @@ public function toSlack(mixed $notifiable): ChannelIdSlackMessage|SlackMessage ->to(Horizon::$slackChannel) ->error() ->content($text) - ->attachment(function ($attachment) use ($title, $content) { + ->attachment(function (SlackAttachment $attachment) use ($title, $content): void { $attachment->title($title) ->content($content); }); diff --git a/src/inertia/config/inertia.php b/src/inertia/config/inertia.php index 7c558c1aeb..15afbd00e2 100644 --- a/src/inertia/config/inertia.php +++ b/src/inertia/config/inertia.php @@ -147,6 +147,19 @@ 'expose_shared_prop_keys' => true, + /* + |-------------------------------------------------------------------------- + | Previous URL + |-------------------------------------------------------------------------- + | + | Hypervel's session middleware doesn't store the previous URL and route for + | Inertia visits, as they are sent as AJAX requests. Enable this option to + | store them for client-side visits as well, excluding partial reloads. + | + */ + + 'store_previous_url' => false, + /* |-------------------------------------------------------------------------- | History @@ -220,4 +233,17 @@ ], ], ], + + /* + |-------------------------------------------------------------------------- + | Big Integers + |-------------------------------------------------------------------------- + | + | When enabled, integers outside JavaScript's safe range arrive in the + | frontend as native BigInt values instead of losing precision. Single + | responses may opt in or out using the `preserveBigIntegers` method. + | + */ + + 'preserve_big_integers' => (bool) env('INERTIA_PRESERVE_BIG_INTEGERS', false), ]; diff --git a/src/inertia/src/Commands/StopSsr.php b/src/inertia/src/Commands/StopSsr.php index c53cd80e3a..fb88f0895b 100644 --- a/src/inertia/src/Commands/StopSsr.php +++ b/src/inertia/src/Commands/StopSsr.php @@ -15,7 +15,7 @@ class StopSsr extends Command /** * The console command name. */ - protected ?string $signature = 'inertia:stop-ssr'; + protected ?string $signature = 'inertia:stop-ssr {--graceful : Return a successful exit code when the SSR server is not running}'; /** * The console command description. @@ -27,7 +27,21 @@ class StopSsr extends Command */ public function handle(HttpGateway $gateway): int { - if (! $gateway->isHealthy()) { + try { + $healthy = $gateway->checkHealth(); + } catch (ConnectionException) { + // Only an unreachable server counts as not running. An unhealthy + // response still means a server answered on the SSR URL. + if ($this->option('graceful')) { + $this->comment('Inertia SSR server is not running.'); + + return self::SUCCESS; + } + + $healthy = false; + } + + if (! $healthy) { $this->error('Unable to connect to Inertia SSR server.'); return self::FAILURE; diff --git a/src/inertia/src/InertiaServiceProvider.php b/src/inertia/src/InertiaServiceProvider.php index 0d09071813..ab1c0f31f7 100644 --- a/src/inertia/src/InertiaServiceProvider.php +++ b/src/inertia/src/InertiaServiceProvider.php @@ -66,7 +66,7 @@ public function register(): void public function boot(HttpFactory $http, Repository $config): void { $this->registerConsoleCommands(); - $this->pushRedirectMiddleware(); + $this->registerRedirectMiddleware(); // A null timeout is left out so the HTTP client's global options apply. $http->registerConnection(HttpGateway::CONNECTION, array_filter([ @@ -82,10 +82,11 @@ public function boot(HttpFactory $http, Repository $config): void /** * Register the global redirect middleware for Inertia requests. */ - protected function pushRedirectMiddleware(): void + protected function registerRedirectMiddleware(): void { $this->callAfterResolving(HttpKernelContract::class, function (HttpKernelContract $kernel) { $kernel->pushMiddleware(Middleware\EnsureGetOnRedirect::class); + $kernel->prependMiddleware(Middleware\EnsureDeferredCallbacksRun::class); }); } diff --git a/src/inertia/src/Middleware.php b/src/inertia/src/Middleware.php index 9c7b483388..eb2e48dbba 100644 --- a/src/inertia/src/Middleware.php +++ b/src/inertia/src/Middleware.php @@ -5,12 +5,14 @@ namespace Hypervel\Inertia; use Closure; +use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Inertia\DevTools\DevTools; use Hypervel\Inertia\Ssr\ExcludesSsrPaths; use Hypervel\Inertia\Ssr\Gateway; use Hypervel\Inertia\Support\Header; use Hypervel\Inertia\Support\SessionKey; +use Hypervel\Routing\Route; use Hypervel\Session\Store; use Hypervel\Support\Facades\Redirect; use Hypervel\Support\MessageBag; @@ -174,6 +176,8 @@ public function handle(Request $request, Closure $next): Response return $response; } + $originalResponse = $response; + if ($request->method() === 'GET' && $request->header(Header::VERSION, '') !== Inertia::getVersion()) { $response = $this->onVersionChange($request, $response); } @@ -182,6 +186,9 @@ public function handle(Request $request, Closure $next): Response $response = $this->onEmptyResponse($request, $response); } + // Back redirects must read the previous location before this visit replaces it. + $this->storeCurrentUrl($request, $originalResponse); + if ($response->getStatusCode() === 302 && in_array($request->method(), ['PUT', 'PATCH', 'DELETE'], true)) { $response->setStatusCode(303); } @@ -197,6 +204,55 @@ public function handle(Request $request, Closure $next): Response return $response; } + /** + * Store the current URL and route as the previous location, which Hypervel's + * session middleware skips for Inertia visits. + */ + protected function storeCurrentUrl(Request $request, Response $response): void + { + if (! $this->shouldStoreCurrentUrl($request, $response)) { + return; + } + + /** @var Store $session */ + $session = $request->session(); + $session->setPreviousUrl($request->fullUrl()); + $session->setPreviousRoute($request->route()?->getName()); + } + + /** + * Determine if the visit should be stored as the previous location. Partial + * reloads are excluded, since deferred props, polling, and infinite scroll + * requests aren't navigations the user came from. + */ + public function shouldStoreCurrentUrl(Request $request, Response $response): bool + { + if (! config()->boolean('inertia.store_previous_url') + || ! $request->hasSession() + || ! $request->isMethod('GET') + || ! $request->route() instanceof Route + || ! $request->ajax() + || $request->prefetch() + || $request->isPrecognitive()) { + return false; + } + + return ! $this->isPartialReload($request, $response); + } + + /** + * Determine if the request is a partial reload of the component that was rendered. + */ + protected function isPartialReload(Request $request, Response $response): bool + { + if (! $component = $request->header(Header::PARTIAL_COMPONENT)) { + return false; + } + + return $response instanceof JsonResponse + && $component === data_get($response->getOriginalContent(), 'component'); + } + /** * Add the Inertia header to the response's Vary list. */ diff --git a/src/inertia/src/Middleware/EnsureDeferredCallbacksRun.php b/src/inertia/src/Middleware/EnsureDeferredCallbacksRun.php new file mode 100644 index 0000000000..ef09f5ad0b --- /dev/null +++ b/src/inertia/src/Middleware/EnsureDeferredCallbacksRun.php @@ -0,0 +1,57 @@ +isClientRedirect($response)) { + return $response; + } + + $callbacks = app(DeferredCallbackCollection::class); + + for ($index = 0, $count = count($callbacks); $index < $count; ++$index) { + $callbacks[$index]->always(); + } + + return $response; + } + + /** + * Determine if the response instructs the client to perform the redirect itself. + */ + protected function isClientRedirect(Response $response): bool + { + if ($response->getStatusCode() !== 409) { + return false; + } + + if ($response->headers->has(Header::REDIRECT)) { + return true; + } + + // A location visit caused by an asset version mismatch carries the new version + // and makes the client replay the request, so the callbacks registered by + // that replay run instead. + return $response->headers->has(Header::LOCATION) + && ! $response->headers->has(Header::VERSION); + } +} diff --git a/src/inertia/src/PreservesBigIntegers.php b/src/inertia/src/PreservesBigIntegers.php new file mode 100644 index 0000000000..04cde1e864 --- /dev/null +++ b/src/inertia/src/PreservesBigIntegers.php @@ -0,0 +1,161 @@ + + */ + protected static array $plainJsonClasses = []; + + /** + * Wrap big integers only when this response opted in. + */ + protected function encodeBigIntegersWhenEnabled(mixed $value): mixed + { + return $this->preserveBigIntegers ? $this->encodeBigIntegers($value) : $value; + } + + /** + * Wrap integers outside JavaScript's safe integer range as a marker so the + * frontend can revive them as native BigInt values without losing + * precision when the JSON response is parsed. + * + * @param null|SplObjectStorage $seen + */ + protected function encodeBigIntegers(mixed $value, ?SplObjectStorage $seen = null): mixed + { + if (is_int($value)) { + return $value > static::MAX_SAFE_INTEGER || $value < -static::MAX_SAFE_INTEGER + ? [static::BIG_INTEGER_KEY => (string) $value] + : $value; + } + + if (is_array($value)) { + return $this->encodeBigIntegersInArray($value, $seen); + } + + if (! is_object($value)) { + return $value; + } + + $seen ??= new SplObjectStorage; + + // Only ancestors are tracked, so a self-reference stops here and is left + // for json_encode to report, while a value shared by two branches is + // still encoded in both. + if ($seen->offsetExists($value)) { + return $value; + } + + $seen->offsetSet($value); + + $encoded = $this->encodeBigIntegersInObject($value, $seen); + + $seen->offsetUnset($value); + + return $encoded; + } + + /** + * Wrap the big integers held by the given array. + * + * @param array $value + * @param null|SplObjectStorage $seen + * @return array + */ + protected function encodeBigIntegersInArray(array $value, ?SplObjectStorage $seen): array + { + foreach ($value as $key => $nested) { + $value[$key] = $this->encodeBigIntegers($nested, $seen); + } + + return $value; + } + + /** + * Wrap the big integers held by the given object, unwrapping it the same + * way json_encode would so the shape it emits is unchanged. Objects are + * cast back so they keep serializing as a JSON object, not an array. + * + * @param SplObjectStorage $seen + */ + protected function encodeBigIntegersInObject(object $value, SplObjectStorage $seen): mixed + { + if ($value instanceof BackedEnum) { + return $this->encodeBigIntegers($value->value, $seen); + } + + // A pure enum has no JSON representation. Walking its properties would + // invent one, so it is left for json_encode to reject as before. + if ($value instanceof UnitEnum) { + return $value; + } + + if ($value instanceof JsonSerializable) { + return $this->encodeBigIntegers($value->jsonSerialize(), $seen); + } + + // An internal class such as DateTime, or a class extending one, serializes + // through its own handler, so walking its public properties would change + // the shape rather than just the integers. + if (! $this->hasPlainJsonRepresentation($value)) { + return $value; + } + + return (object) $this->encodeBigIntegersInArray(get_object_vars($value), $seen); + } + + /** + * Determine if the object's public properties are what json_encode emits. + */ + protected function hasPlainJsonRepresentation(object $value): bool + { + if ($value instanceof stdClass) { + return true; + } + + $cached = static::$plainJsonClasses[$value::class] ?? null; + + if ($cached !== null) { + return $cached; + } + + foreach ([$value::class, ...class_parents($value)] as $class) { + if ((new ReflectionClass($class))->isInternal()) { + return static::$plainJsonClasses[$value::class] = false; + } + } + + return static::$plainJsonClasses[$value::class] = true; + } +} diff --git a/src/inertia/src/Response.php b/src/inertia/src/Response.php index b3b55568fe..00a786ff87 100644 --- a/src/inertia/src/Response.php +++ b/src/inertia/src/Response.php @@ -22,6 +22,7 @@ class Response implements Responsable { use Macroable; + use PreservesBigIntegers; /** * The name of the root component. @@ -93,6 +94,7 @@ public function __construct( string $version = '', bool $encryptHistory = false, ?Closure $urlResolver = null, + bool $preserveBigIntegers = false, ) { $this->component = $component; $this->sharedProps = $sharedProps; @@ -103,6 +105,19 @@ public function __construct( $this->preserveFragment = session()->pull(SessionKey::PRESERVE_FRAGMENT, false); $this->encryptHistory = $encryptHistory; $this->urlResolver = $urlResolver; + $this->preserveBigIntegers = $preserveBigIntegers; + } + + /** + * Preserve integers outside JavaScript's safe range as BigInt values. + * + * @return $this + */ + public function preserveBigIntegers(bool $preserve = true): self + { + $this->preserveBigIntegers = $preserve; + + return $this; } /** @@ -174,6 +189,8 @@ public function toResponse(Request $request): SymfonyResponse $resolver = new PropsResolver($request, $this->component); [$resolvedProps, $resolvedMetadata] = $resolver->resolve($this->sharedProps, $this->props); + $resolvedProps = $this->encodeBigIntegersWhenEnabled($resolvedProps); + $page = array_merge( [ 'component' => $this->component, @@ -182,6 +199,7 @@ public function toResponse(Request $request): SymfonyResponse 'version' => $this->version, ], $resolvedMetadata, + $this->resolvePreserveBigIntegers($request), $this->resolveClearHistory($request), $this->resolveEncryptHistory($request), $this->resolveFlashData($request), @@ -232,7 +250,21 @@ protected function resolveFlashData(Request $request): array { $flash = Inertia::pullFlashed($request); - return $flash ? ['flash' => $flash] : []; + if (! $flash) { + return []; + } + + return ['flash' => $this->encodeBigIntegersWhenEnabled($flash)]; + } + + /** + * Resolve the preserve big integers flag. + * + * @return array + */ + protected function resolvePreserveBigIntegers(Request $request): array + { + return $this->preserveBigIntegers ? ['preserveBigIntegers' => true] : []; } /** @@ -280,5 +312,6 @@ protected function finishUrlWithTrailingSlash(string $url): string public static function flushState(): void { static::flushMacros(); + static::$plainJsonClasses = []; } } diff --git a/src/inertia/src/ResponseFactory.php b/src/inertia/src/ResponseFactory.php index 5fc64c6e7a..c1ba795d67 100644 --- a/src/inertia/src/ResponseFactory.php +++ b/src/inertia/src/ResponseFactory.php @@ -351,6 +351,7 @@ public function render(mixed $component, mixed $props = []): Response $this->getVersion(), $state->encryptHistory ?? config()->boolean('inertia.history.encrypt', false), $state->urlResolver, + preserveBigIntegers: config()->boolean('inertia.preserve_big_integers'), ); DevTools::recorder()?->pageRendering($component, $response); diff --git a/src/inertia/src/Ssr/HttpGateway.php b/src/inertia/src/Ssr/HttpGateway.php index 690c431ecf..2ecf877bc8 100644 --- a/src/inertia/src/Ssr/HttpGateway.php +++ b/src/inertia/src/Ssr/HttpGateway.php @@ -240,11 +240,27 @@ protected function ssrIsEnabled(Request $request): bool * Determine if the SSR server is healthy. */ public function isHealthy(): bool + { + try { + return $this->checkHealth(); + } catch (ConnectionException) { + return false; + } + } + + /** + * Determine if the SSR server is healthy, throwing when it cannot be reached. + * + * @throws ConnectionException + */ + public function checkHealth(): bool { $pendingRequest = $this->pendingRequest(); try { return $pendingRequest->get($this->getProductionUrl('/health'))->successful(); + } catch (ConnectionException $exception) { + throw $exception; } catch (HttpClientException) { return false; } diff --git a/src/inertia/src/Testing/AssertableInertia.php b/src/inertia/src/Testing/AssertableInertia.php index 07edb85b3c..8cfee0b347 100644 --- a/src/inertia/src/Testing/AssertableInertia.php +++ b/src/inertia/src/Testing/AssertableInertia.php @@ -80,6 +80,11 @@ public static function fromTestResponse(TestResponse $response): self PHPUnit::fail('Not a valid Inertia response.'); } + if (($page['preserveBigIntegers'] ?? false) === true) { + $page['props'] = static::decodeBigIntegers($page['props']); + $page['flash'] = static::decodeBigIntegers($page['flash'] ?? []); + } + $instance = static::fromArray($page['props']); $instance->component = $page['component']; $instance->url = $page['url']; @@ -92,6 +97,28 @@ public static function fromTestResponse(TestResponse $response): self return $instance; } + /** + * Turn big integer markers back into integers, so assertions are made + * against the values that were passed to the response. + * + * @param array $value + * @return array + */ + protected static function decodeBigIntegers(array $value): array + { + foreach ($value as $key => $nested) { + if (! is_array($nested)) { + continue; + } + + $value[$key] = is_string($nested['$bigint'] ?? null) + ? (int) $nested['$bigint'] + : static::decodeBigIntegers($nested); + } + + return $value; + } + /** * Assert that the page uses the given component. * diff --git a/src/slack-notification-channel/README.md b/src/slack-notification-channel/README.md index c39359a762..27af772498 100644 --- a/src/slack-notification-channel/README.md +++ b/src/slack-notification-channel/README.md @@ -3,4 +3,8 @@ Slack Notification Channel for Hypervel Documentation: https://hypervel.org/docs/notifications +## Differences From Laravel + +- Slack select option values are preserved exactly, rather than lowercased and stripped of characters, so distinct values remain distinct. Interaction handlers should match the original values. + Ported from: https://github.com/laravel/slack-notification-channel diff --git a/src/slack-notification-channel/src/Channels/SlackWebhookChannel.php b/src/slack-notification-channel/src/Channels/SlackWebhookChannel.php index 2c8df4a2c7..6388f13d99 100644 --- a/src/slack-notification-channel/src/Channels/SlackWebhookChannel.php +++ b/src/slack-notification-channel/src/Channels/SlackWebhookChannel.php @@ -69,7 +69,7 @@ public function buildJsonPayload(SlackMessage|LegacySlackMessage $message): arra */ protected function attachments(LegacySlackMessage $message): array { - return Collection::make($message->attachments)->map(function ($attachment) use ($message) { + return Collection::make($message->attachments)->map(function (SlackAttachment $attachment) use ($message): array { return array_filter([ 'actions' => $attachment->actions, 'author_icon' => $attachment->authorIcon, @@ -98,7 +98,7 @@ protected function attachments(LegacySlackMessage $message): array */ protected function fields(SlackAttachment $attachment): array { - return Collection::make($attachment->fields)->map(function ($value, $key) { + return Collection::make($attachment->fields)->map(function (mixed $value, int|string $key): array { if ($value instanceof SlackAttachmentField) { return $value->toArray(); } diff --git a/src/slack-notification-channel/src/Messages/SlackAttachment.php b/src/slack-notification-channel/src/Messages/SlackAttachment.php index b3e0e18907..a6b11e334e 100644 --- a/src/slack-notification-channel/src/Messages/SlackAttachment.php +++ b/src/slack-notification-channel/src/Messages/SlackAttachment.php @@ -101,7 +101,7 @@ class SlackAttachment /** * The attachment's callback ID. */ - public string $callbackId = ''; + public ?string $callbackId = null; /** * Set the title of the attachment. @@ -159,7 +159,8 @@ public function color(string $color): static */ public function field(Closure|string $title, string $content = ''): static { - if (is_callable($title)) { + // Titles such as "Date" also name PHP functions, so is_callable() would call them. + if ($title instanceof Closure) { $callback = $title; $callback($attachmentField = new SlackAttachmentField); diff --git a/src/slack-notification-channel/src/Messages/SlackMessage.php b/src/slack-notification-channel/src/Messages/SlackMessage.php index 3af1ab5733..8c5e769621 100644 --- a/src/slack-notification-channel/src/Messages/SlackMessage.php +++ b/src/slack-notification-channel/src/Messages/SlackMessage.php @@ -5,7 +5,14 @@ namespace Hypervel\Notifications\Messages; use Closure; - +use Hypervel\Notifications\Slack\SlackMessage as BlockKitSlackMessage; + +/** + * Legacy Slack message with attachments. + * + * @link https://api.slack.com/legacy/outmoded-messaging + * @see BlockKitSlackMessage + */ class SlackMessage { /** diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ActionsBlock.php b/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ActionsBlock.php index 11c16f73ec..ef9206c49d 100644 --- a/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ActionsBlock.php +++ b/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ActionsBlock.php @@ -48,7 +48,7 @@ public function id(string $id): static */ public function button(string $text): ButtonElement { - return tap(new ButtonElement($text), function (ButtonElement $button) { + return tap(new ButtonElement($text), function (ButtonElement $button): void { $this->elements[] = $button; }); } @@ -58,7 +58,7 @@ public function button(string $text): ButtonElement */ public function staticSelect(string $text): StaticSelectElement { - return tap(new StaticSelectElement($text), function (StaticSelectElement $select) { + return tap(new StaticSelectElement($text), function (StaticSelectElement $select): void { $this->elements[] = $select; }); } @@ -68,7 +68,7 @@ public function staticSelect(string $text): StaticSelectElement */ public function usersSelect(string $text): UsersSelectElement { - return tap(new UsersSelectElement($text), function (UsersSelectElement $select) { + return tap(new UsersSelectElement($text), function (UsersSelectElement $select): void { $this->elements[] = $select; }); } @@ -96,7 +96,7 @@ public function toArray(): array return array_merge([ 'type' => 'actions', - 'elements' => array_map(fn (Arrayable $element) => $element->toArray(), $this->elements), + 'elements' => array_map(fn (Arrayable $element): array => $element->toArray(), $this->elements), ], $optionalFields); } } diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ContextBlock.php b/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ContextBlock.php index 70ab10533c..146e2f45e9 100644 --- a/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ContextBlock.php +++ b/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ContextBlock.php @@ -47,7 +47,7 @@ public function id(string $id): static */ public function image(string $imageUrl, ?string $altText = null): ImageElement { - return tap(new ImageElement($imageUrl, $altText), function (ImageElement $element) { + return tap(new ImageElement($imageUrl, $altText), function (ImageElement $element): void { $this->elements[] = $element; }); } @@ -57,7 +57,7 @@ public function image(string $imageUrl, ?string $altText = null): ImageElement */ public function text(string $text): TextObject { - return tap(new TextObject($text), function (TextObject $element) { + return tap(new TextObject($text), function (TextObject $element): void { $this->elements[] = $element; }); } @@ -85,7 +85,7 @@ public function toArray(): array return array_merge([ 'type' => 'context', - 'elements' => array_map(fn (Arrayable $element) => $element->toArray(), $this->elements), + 'elements' => array_map(fn (Arrayable $element): array => $element->toArray(), $this->elements), ], $optionalFields); } } diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Blocks/SectionBlock.php b/src/slack-notification-channel/src/Slack/BlockKit/Blocks/SectionBlock.php index 9d644dafb9..4f8776eb10 100644 --- a/src/slack-notification-channel/src/Slack/BlockKit/Blocks/SectionBlock.php +++ b/src/slack-notification-channel/src/Slack/BlockKit/Blocks/SectionBlock.php @@ -97,7 +97,7 @@ public function toArray(): array } if (is_null($this->text) && empty($this->fields)) { - throw new LogicException('A section requires at least one block, or the text to be set.'); + throw new LogicException('A section requires at least one field, or the text to be set.'); } if (count($this->fields) > 10) { @@ -108,7 +108,7 @@ public function toArray(): array 'text' => $this->text?->toArray(), 'block_id' => $this->blockId, 'accessory' => $this->accessory?->toArray(), - 'fields' => array_map(fn (Arrayable $element) => $element->toArray(), $this->fields), + 'fields' => array_map(fn (Arrayable $element): array => $element->toArray(), $this->fields), ], static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []); return array_merge([ diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Composites/ConfirmObject.php b/src/slack-notification-channel/src/Slack/BlockKit/Composites/ConfirmObject.php index 68bba607cd..ccb26a2e3a 100644 --- a/src/slack-notification-channel/src/Slack/BlockKit/Composites/ConfirmObject.php +++ b/src/slack-notification-channel/src/Slack/BlockKit/Composites/ConfirmObject.php @@ -98,7 +98,7 @@ public function deny(string $label): PlainTextOnlyTextObject } /** - * Marks the confirm dialog as dangerous. + * Mark the confirm dialog as dangerous. */ public function danger(): static { diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php b/src/slack-notification-channel/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php index 1237fa2c48..7480da3517 100644 --- a/src/slack-notification-channel/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php +++ b/src/slack-notification-channel/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php @@ -10,7 +10,7 @@ class PlainTextOnlyTextObject implements ObjectContract { /** - * The formatting to use for this text object. + * The text for this text object. */ protected string $text; diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Composites/TextObject.php b/src/slack-notification-channel/src/Slack/BlockKit/Composites/TextObject.php index c7df48f302..095807b7f8 100644 --- a/src/slack-notification-channel/src/Slack/BlockKit/Composites/TextObject.php +++ b/src/slack-notification-channel/src/Slack/BlockKit/Composites/TextObject.php @@ -23,7 +23,7 @@ class TextObject extends PlainTextOnlyTextObject protected ?bool $verbatim = null; /** - * Changes the formatting of this text object to mrkdwn. + * Change the formatting of this text object to mrkdwn. */ public function markdown(): static { diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/SelectElement.php b/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/SelectElement.php index ef6958b475..163f50aa2a 100644 --- a/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/SelectElement.php +++ b/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/SelectElement.php @@ -79,6 +79,6 @@ public function toArray(): array 'action_id' => $this->actionId, 'placeholder' => $this->placeholder?->toArray(), 'focus_on_load' => $this->focusOnLoad, - ], static fn ($value): bool => $value !== null); + ], static fn (mixed $value): bool => $value !== null); } } diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/StaticSelectElement.php b/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/StaticSelectElement.php index f10a568db8..6d214c5af7 100644 --- a/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/StaticSelectElement.php +++ b/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/StaticSelectElement.php @@ -73,12 +73,12 @@ public function toArray(): array $options = array_values($this->options); - $options = array_map(fn (SelectOption $option) => $option->toArray(), $options); + $options = array_map(fn (SelectOption $option): array => $option->toArray(), $options); return array_filter(array_merge([ 'type' => 'static_select', 'options' => $options, 'initial_option' => $this->initialOption?->toArray(), - ], parent::toArray()), fn ($value): bool => $value !== null); + ], parent::toArray()), fn (mixed $value): bool => $value !== null); } } diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/UsersSelectElement.php b/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/UsersSelectElement.php index 5c4eb00cb8..400e6b0303 100644 --- a/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/UsersSelectElement.php +++ b/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/UsersSelectElement.php @@ -41,6 +41,6 @@ public function toArray(): array return array_filter(array_merge([ 'type' => 'users_select', 'initial_user' => $this->initialUser, - ], parent::toArray()), fn ($value): bool => $value !== null); + ], parent::toArray()), fn (mixed $value): bool => $value !== null); } } diff --git a/src/slack-notification-channel/src/Slack/SlackChannel.php b/src/slack-notification-channel/src/Slack/SlackChannel.php index 18264368cd..960abd0988 100644 --- a/src/slack-notification-channel/src/Slack/SlackChannel.php +++ b/src/slack-notification-channel/src/Slack/SlackChannel.php @@ -18,8 +18,6 @@ class SlackChannel */ public const string CONNECTION = 'slack-notifications'; - protected const string SLACK_API_URL = 'https://slack.com/api/chat.postMessage'; - /** * Create a new Slack channel instance. */ @@ -50,7 +48,7 @@ public function send(mixed $notifiable, Notification $notification): ?Response $response = $this->http->connection(self::CONNECTION) ->asJson() ->withToken($route->token) - ->post(static::SLACK_API_URL, $payload) + ->post('https://slack.com/api/chat.postMessage', $payload) ->throw(); if ($response->successful() && $response->json('ok') === false) { diff --git a/src/slack-notification-channel/src/Slack/SlackMessage.php b/src/slack-notification-channel/src/Slack/SlackMessage.php index 5f013a1e0e..83d75dbe01 100644 --- a/src/slack-notification-channel/src/Slack/SlackMessage.php +++ b/src/slack-notification-channel/src/Slack/SlackMessage.php @@ -305,7 +305,7 @@ public function toArray(): array $optionalFields = array_filter([ 'text' => $this->text, - 'blocks' => ! empty($this->blocks) ? array_map(fn ($block) => $block instanceof BlockContract ? $block->toArray() : $block, $this->blocks) : null, + 'blocks' => ! empty($this->blocks) ? array_map(fn (array|BlockContract $block): array => $block instanceof BlockContract ? $block->toArray() : $block, $this->blocks) : null, 'icon_emoji' => $this->icon, 'icon_url' => $this->image, 'metadata' => $this->metaData?->toArray(), @@ -315,7 +315,7 @@ public function toArray(): array 'unfurl_links' => $this->unfurlLinks, 'unfurl_media' => $this->unfurlMedia, 'username' => $this->username, - ], fn ($value) => $value !== null); + ], fn (mixed $value): bool => $value !== null); return array_merge([ 'channel' => $this->channel, @@ -323,7 +323,7 @@ public function toArray(): array } /** - * Get the payload as a URL to the Slack Block Kit Builder. + * Get the Block Kit URL for the message. * * @throws JsonException */ diff --git a/src/slack-notification-channel/src/SlackChannelServiceProvider.php b/src/slack-notification-channel/src/SlackChannelServiceProvider.php index e783657e6c..470c90fb98 100644 --- a/src/slack-notification-channel/src/SlackChannelServiceProvider.php +++ b/src/slack-notification-channel/src/SlackChannelServiceProvider.php @@ -4,8 +4,10 @@ namespace Hypervel\Notifications; +use GuzzleHttp\Client as HttpClient; use Hypervel\Contracts\Container\Container; use Hypervel\Http\Client\Factory; +use Hypervel\Notifications\Channels\SlackWebhookChannel; use Hypervel\Notifications\Slack\SlackChannel; use Hypervel\Support\Facades\Notification; use Hypervel\Support\ServiceProvider; @@ -22,6 +24,11 @@ public function register(): void return $app->make(SlackNotificationRouterChannel::class); }); }); + + // Guzzle has no request timeout by default, so a stalled webhook host would hold the send indefinitely. + $this->app->when(SlackWebhookChannel::class) + ->needs(HttpClient::class) + ->give(fn (): HttpClient => new HttpClient(['connect_timeout' => 10, 'timeout' => 30])); } /** diff --git a/src/telescope/src/TelescopeServiceProvider.php b/src/telescope/src/TelescopeServiceProvider.php index 5b46c4d0c2..6798fcc403 100644 --- a/src/telescope/src/TelescopeServiceProvider.php +++ b/src/telescope/src/TelescopeServiceProvider.php @@ -17,7 +17,6 @@ use Hypervel\Telescope\Contracts\EntriesRepository; use Hypervel\Telescope\Contracts\PrunableRepository; use Hypervel\Telescope\Storage\DatabaseEntriesRepository; -use Hypervel\Telescope\Watchers\CacheWatcher; use Hypervel\Telescope\Watchers\ClientRequestWatcher; use Hypervel\Telescope\Watchers\RedisWatcher; @@ -139,7 +138,6 @@ public function register(): void } $this->registerRedisEvents(); - $this->registerCacheEvents(); $this->registerGuzzleHttpClientAspect(); } @@ -155,18 +153,6 @@ protected function registerRedisEvents(): void RedisWatcher::enableRedisEvents($this->app); } - /** - * Register the Cache events if the watcher is enabled. - */ - protected function registerCacheEvents(): void - { - if (! $this->watcherIsEnabled(CacheWatcher::class)) { - return; - } - - CacheWatcher::enableCacheEvents($this->app); - } - /** * Register the Guzzle HTTP client aspect if the watcher is enabled. */ diff --git a/src/telescope/src/Watchers/CacheWatcher.php b/src/telescope/src/Watchers/CacheWatcher.php index b5d731a1ee..8061218f6a 100644 --- a/src/telescope/src/Watchers/CacheWatcher.php +++ b/src/telescope/src/Watchers/CacheWatcher.php @@ -17,20 +17,11 @@ class CacheWatcher extends Watcher { - /** - * Indicates if the cache event is enabled. - */ - protected static bool $eventsEnabled = false; - /** * Register the watcher. */ public function register(Application $app): void { - if (! static::$eventsEnabled) { - return; - } - $event = $app->make(Dispatcher::class); $event->listen(CacheHit::class, [$this, 'recordCacheHit']); @@ -39,23 +30,6 @@ public function register(Application $app): void $event->listen(KeyForgotten::class, [$this, 'recordKeyForgotten']); } - /** - * Enable Cache events. - * - * Boot-only. Must be called before the Cache is initialized. Mutates - * process-global config and a static flag; runtime use races across - * coroutines. - */ - public static function enableCacheEvents(Application $app): void - { - $config = $app->make('config'); - foreach (array_keys($config->array('cache.stores')) as $store) { - $config->set("cache.stores.{$store}.events", true); - } - - static::$eventsEnabled = true; - } - /** * Record a cache key was found. */ @@ -156,12 +130,4 @@ private function shouldIgnore(mixed $event): bool 'telescope:*', ]), $event->key); } - - /** - * Flush all static state. - */ - public static function flushState(): void - { - static::$eventsEnabled = false; - } } diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 3df6da90f0..033b6f0823 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -492,7 +492,6 @@ protected function flushSentryState(): void protected function flushTelescopeState(): void { $this->callIfExists(\Hypervel\Telescope\Telescope::class, 'flushState'); - $this->callIfExists(\Hypervel\Telescope\Watchers\CacheWatcher::class, 'flushState'); $this->callIfExists(\Hypervel\Telescope\Watchers\DumpWatcher::class, 'flushState'); $this->callIfExists(\Hypervel\Telescope\Watchers\RedisWatcher::class, 'flushState'); } diff --git a/tests/Database/DatabaseReadPoolTest.php b/tests/Database/DatabaseReadPoolTest.php index 5b9aafd3d3..524e786224 100644 --- a/tests/Database/DatabaseReadPoolTest.php +++ b/tests/Database/DatabaseReadPoolTest.php @@ -69,7 +69,7 @@ public function testReadRecordsRequireTheSameEffectivePoolOptions(array $secondO if ($conflicting) { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('must use the same effective pool options'); + $this->expectExceptionMessageIsOrContains('must use the same effective pool options'); } $pool = new DatabasePool($this->app, 'read_pool_options::read'); @@ -110,7 +110,7 @@ public function testEveryReadRecordIsCheckedForAnInMemorySqliteDatabase(): void ]]); $this->app->instance('db.factory', new ReadPoolConnectionFactory($this->app)); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('cannot use a derived read pool for in-memory SQLite'); + $this->expectExceptionMessageIsOrContains('cannot use a derived read pool for in-memory SQLite'); new DatabasePool($this->app, 'read_pool_memory::read'); } diff --git a/tests/Filesystem/AwsS3V3AdapterTest.php b/tests/Filesystem/AwsS3V3AdapterTest.php index 60bee29c3d..2f2af753fd 100644 --- a/tests/Filesystem/AwsS3V3AdapterTest.php +++ b/tests/Filesystem/AwsS3V3AdapterTest.php @@ -309,7 +309,11 @@ public function testReadThroughRangesPreserveNativeRequestsAndTheOuterFailurePol function (CommandInterface $request) use (&$command): Result { $command = $request; - return new Result(['Body' => Utils::streamFor('range')]); + [$reader, $writer] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + fwrite($writer, 'range'); + fclose($writer); + + return new Result(['Body' => Utils::streamFor($reader)]); }, $failure, ]); diff --git a/tests/Filesystem/ClientPooledFilesystemTest.php b/tests/Filesystem/ClientPooledFilesystemTest.php index b3a19c81ec..46aef5cfdf 100644 --- a/tests/Filesystem/ClientPooledFilesystemTest.php +++ b/tests/Filesystem/ClientPooledFilesystemTest.php @@ -399,6 +399,37 @@ function (object $client) use (&$releaseCalls): void { $this->assertSame(1, $releaseCalls); } + public function testBufferedReadStreamReleasesTheClientBeforeConsumption(): void + { + $clientCreations = 0; + $stackCreations = 0; + $releaseCalls = 0; + $buffer = fopen('php://temp', 'w+b'); + fwrite($buffer, 'buffered contents'); + rewind($buffer); + + $stack = m::mock(FilesystemAdapter::class); + $stack->shouldReceive('readStream')->once()->with('file.txt')->andReturn($buffer); + $disk = $this->disk( + $clientCreations, + $stackCreations, + static function (object $client) use (&$releaseCalls): void { + ++$releaseCalls; + }, + static fn (object $client): FilesystemAdapter => $stack, + ); + + try { + $stream = $disk->readStream('file.txt'); + + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedCount()); + $this->assertSame(1, $releaseCalls); + $this->assertSame('buffered contents', stream_get_contents($stream)); + } finally { + fclose($buffer); + } + } + public function testNonResourceReadStreamResultReleasesImmediately(): void { $clientCreations = 0; diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index b3c390381c..112e1ec938 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -1212,6 +1212,24 @@ public function testPoolableDriver(): void $this->assertInstanceOf(FilesystemPoolProxy::class, $filesystem->disk('local')); } + #[TestWith(['ftp'])] + #[TestWith(['sftp'])] + public function testConnectionHoldingDisksArePooledWhole(string $driver): void + { + // Each adapter keeps one connection, which concurrent coroutines must not share. + $filesystem = new FilesystemManager($this->getContainer([ + 'disks' => [ + 'remote' => [ + 'driver' => $driver, + 'host' => 'files.example.com', + 'username' => 'hypervel', + ], + ], + ])); + + $this->assertInstanceOf(FilesystemPoolProxy::class, $filesystem->disk('remote')); + } + public function testS3DisksWithTheSameClientConfigShareOneClientPoolAcrossBuckets(): void { $container = $this->getContainer([ diff --git a/tests/Filesystem/FilesystemPoolProxyTest.php b/tests/Filesystem/FilesystemPoolProxyTest.php index aa1d1a7d25..e971cbcfb7 100644 --- a/tests/Filesystem/FilesystemPoolProxyTest.php +++ b/tests/Filesystem/FilesystemPoolProxyTest.php @@ -6,6 +6,10 @@ use BadMethodCallException; use Closure; +use GuzzleHttp\Psr7\CachingStream; +use GuzzleHttp\Psr7\LimitStream; +use GuzzleHttp\Psr7\PumpStream; +use GuzzleHttp\Psr7\StreamWrapper; use Hypervel\Context\RequestContext; use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract; use Hypervel\Filesystem\FilesystemAdapter; @@ -20,6 +24,7 @@ use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; use League\Flysystem\Filesystem; +use League\Flysystem\FilesystemOperator; use League\Flysystem\Local\LocalFilesystemAdapter; use League\Flysystem\UnableToReadFile; use Mockery as m; @@ -240,6 +245,73 @@ function (object $filesystem) use (&$releaseCalls): void { $this->assertSame(1, $releaseCalls); } + #[DataProvider('bufferedReadProvider')] + public function testBufferedReadsReleaseTheWholeDriverImmediately(string $uri, ?int $start, ?int $end, string $expected): void + { + $driver = m::mock(FilesystemOperator::class); + $driver->shouldReceive('readStream')->once()->with('file.txt')->andReturnUsing(static function () use ($uri): mixed { + $stream = fopen($uri, 'w+b'); + fwrite($stream, '0123456789'); + rewind($stream); + + return $stream; + }); + $proxy = $this->proxy(fn (): FilesystemAdapter => new FilesystemAdapter($driver, $this->adapter)); + $stream = $start === null + ? $proxy->readStream('file.txt') + : $proxy->readStreamRange('file.txt', $start, $end); + + try { + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); + $this->assertSame(1, $this->pools->get('filesystem:driver')->getIdleCount()); + $this->assertSame(0, ftell($stream)); + $this->assertSame(strlen($expected), fstat($stream)['size']); + $this->assertSame($expected, stream_get_contents($stream)); + rewind($stream); + $this->assertSame($expected, stream_get_contents($stream)); + } finally { + fclose($stream); + } + } + + /** + * Provide buffered streams and byte ranges. + * + * @return array + */ + public static function bufferedReadProvider(): array + { + return [ + 'temporary stream' => ['php://temp', null, null, '0123456789'], + 'memory stream' => ['php://memory', null, null, '0123456789'], + 'range spilled to disk' => ['php://temp/maxmemory:1', 3, 5, '345'], + ]; + } + + public function testCachedLiveRangeKeepsTheWholeDriverBorrowedUntilClose(): void + { + $reads = 0; + $source = new PumpStream(static function (int $length) use (&$reads): string|false { + return ++$reads === 1 ? 'remote bytes' : false; + }); + $range = StreamWrapper::getResource(new LimitStream(new CachingStream($source), 3)); + $driver = m::mock(FilesystemOperator::class); + $driver->shouldReceive('readStream')->once()->with('file.txt')->andReturn($range); + $proxy = $this->proxy(fn (): FilesystemAdapter => new FilesystemAdapter($driver, $this->adapter)); + $stream = $proxy->readStream('file.txt'); + + try { + $this->assertSame(0, $reads); + $this->assertSame(1, $this->pools->get('filesystem:driver')->getBorrowedCount()); + $this->assertSame('rem', stream_get_contents($stream)); + $this->assertSame(1, $reads); + } finally { + fclose($stream); + } + + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); + } + public function testOperatorReadsPropagateFailuresAndReleaseTheWholeDriver(): void { $proxy = $this->proxy(fn (): FilesystemAdapter => $this->filesystem()); diff --git a/tests/Fortify/FortifyServiceProviderTest.php b/tests/Fortify/FortifyServiceProviderTest.php index 651eaaf970..0738c81032 100644 --- a/tests/Fortify/FortifyServiceProviderTest.php +++ b/tests/Fortify/FortifyServiceProviderTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Fortify; use Closure; +use Hypervel\Config\Repository as ConfigRepository; use Hypervel\Contracts\Support\Responsable; use Hypervel\Fortify\Contracts\CreatesNewUsers; use Hypervel\Fortify\Contracts\RedirectsIfTwoFactorAuthenticatable; @@ -12,6 +13,8 @@ use Hypervel\Fortify\Fortify; use Hypervel\Fortify\FortifyServiceProvider; use Hypervel\Fortify\RecoveryCode; +use Hypervel\Foundation\Application; +use Hypervel\Foundation\Configuration\ConfigMutationTracker; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Passkeys\Passkeys; @@ -150,6 +153,36 @@ public function testOmittedPasskeySettingsUseTheApplicationAndPackageDefaults(): $this->assertSame(Passkeys::DEFAULT_TIMEOUT, config('passkeys.timeout')); } + public function testPasskeySettingsAreComputedAgainAgainstTheRebuiltConfiguration(): void + { + $masterConfig = new ConfigRepository([ + 'app' => ['url' => 'https://old.example.test', 'key' => 'old-key'], + 'fortify' => ['passkeys' => ['timeout' => 30000]], + ]); + $tracker = new ConfigMutationTracker; + $tracker->observe($masterConfig); + $app = m::mock(Application::class)->makePartial(); + $app->shouldReceive('configurationIsCached')->andReturnFalse(); + $app->shouldReceive('make')->with('config')->andReturn($masterConfig); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn($tracker); + + (new ReflectionMethod(FortifyServiceProvider::class, 'configurePasskeys'))->invoke(new FortifyServiceProvider($app)); + + $this->assertSame('old.example.test', $masterConfig->get('passkeys.relying_party_id')); + + // A worker whose environment changed the application URL and key derives the defaults from them, keeping explicit settings. + $workerConfig = new ConfigRepository([ + 'app' => ['url' => 'https://new.example.test', 'key' => 'new-key'], + 'fortify' => ['passkeys' => ['relying_party_id' => 'passkeys.example.test', 'timeout' => 30000]], + ]); + $tracker->replay($workerConfig); + + $this->assertSame('passkeys.example.test', $workerConfig->get('passkeys.relying_party_id')); + $this->assertSame(['https://new.example.test'], $workerConfig->get('passkeys.allowed_origins')); + $this->assertSame('new-key', $workerConfig->get('passkeys.user_handle_secret')); + $this->assertSame(30000, $workerConfig->get('passkeys.timeout')); + } + #[DefineEnvironment('withTwoFactorAuthentication')] public function testRedirectIfTwoFactorAuthenticatableIsResolvedFreshAfterFlushingScopedInstances(): void { diff --git a/tests/Horizon/HorizonConfigTest.php b/tests/Horizon/HorizonConfigTest.php index ae0ecb71af..52b743f238 100644 --- a/tests/Horizon/HorizonConfigTest.php +++ b/tests/Horizon/HorizonConfigTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Horizon; use Hypervel\Config\Repository as ConfigRepository; +use Hypervel\Container\Container; use Hypervel\Foundation\Application; use Hypervel\Foundation\Configuration\ConfigMutationTracker; use Hypervel\Horizon\HorizonServiceProvider; @@ -73,7 +74,9 @@ public function testOnlyMissingAndBlankNamesUseTheApplicationName(): void 'horizon' => ['name' => $name], ]); $app = m::mock(Application::class)->makePartial(); + $app->shouldReceive('configurationIsCached')->andReturnFalse(); $app->shouldReceive('make')->with('config')->andReturn($config); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn(new ConfigMutationTracker); (new HorizonServiceProviderForTesting($app))->normalize(); @@ -81,6 +84,76 @@ public function testOnlyMissingAndBlankNamesUseTheApplicationName(): void } } + public function testApplicationNameFallbackIsComputedAgainAgainstTheRebuiltConfiguration(): void + { + $masterConfig = new ConfigRepository([ + 'app' => ['name' => 'Old Name'], + 'horizon' => ['name' => null], + ]); + $tracker = new ConfigMutationTracker; + $tracker->observe($masterConfig); + $app = m::mock(Application::class)->makePartial(); + $app->shouldReceive('configurationIsCached')->andReturnFalse(); + $app->shouldReceive('make')->with('config')->andReturn($masterConfig); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn($tracker); + + (new HorizonServiceProviderForTesting($app))->normalize(); + + $this->assertSame('Old Name', $masterConfig->get('horizon.name')); + + // A worker whose environment renamed the application uses the new name, unless Horizon's own name is now set. + $workerConfig = new ConfigRepository([ + 'app' => ['name' => 'New Name'], + 'horizon' => ['name' => null], + ]); + $tracker->replay($workerConfig); + + $this->assertSame('New Name', $workerConfig->get('horizon.name')); + + $namedWorkerConfig = new ConfigRepository([ + 'app' => ['name' => 'New Name'], + 'horizon' => ['name' => 'Queues'], + ]); + $tracker->replay($namedWorkerConfig); + + $this->assertSame('Queues', $namedWorkerConfig->get('horizon.name')); + } + + public function testRedisConnectionIsComputedAgainAgainstTheRebuiltConfiguration(): void + { + $masterConfig = new ConfigRepository([ + 'database' => ['redis' => ['default' => ['host' => 'old-host']]], + 'horizon' => ['prefix' => 'old_horizon:'], + ]); + $tracker = new ConfigMutationTracker; + $tracker->observe($masterConfig); + // Horizon::use() reads the application's configuration, which a rebuild refreshes in place. + $currentConfig = $masterConfig; + $app = m::mock(Application::class)->makePartial(); + $app->shouldReceive('configurationIsCached')->andReturnFalse(); + $app->shouldReceive('make')->with('config')->andReturnUsing(static function () use (&$currentConfig): ConfigRepository { + return $currentConfig; + }); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn($tracker); + Container::setInstance($app); + + (new HorizonServiceProviderForTesting($app))->configureForTest(); + + $this->assertSame('old-host', $masterConfig->get('database.redis.horizon.host')); + $this->assertSame('old_horizon:', $masterConfig->get('database.redis.horizon.prefix')); + + // A worker whose environment moved Redis and changed the prefix connects Horizon with the new settings. + $currentConfig = $workerConfig = new ConfigRepository([ + 'database' => ['redis' => ['default' => ['host' => 'new-host']]], + 'horizon' => ['prefix' => 'new_horizon:'], + ]); + $tracker->replay($workerConfig); + + $this->assertSame('new-host', $workerConfig->get('database.redis.horizon.host')); + $this->assertSame('new_horizon:', $workerConfig->get('database.redis.horizon.prefix')); + $this->assertSame('new_horizon:', $workerConfig->get('horizon.prefix')); + } + public function testMissingAndBlankPrefixUseApplicationScopedDefault(): void { foreach ([null, ''] as $prefix) { @@ -108,8 +181,19 @@ public function register(): void class HorizonServiceProviderForTesting extends HorizonServiceProvider { + /** + * Normalize the Horizon configuration. + */ public function normalize(): void { $this->normalizeConfig(); } + + /** + * Configure Horizon for the test. + */ + public function configureForTest(): void + { + $this->configure(); + } } diff --git a/tests/Http/Client/CurlStreamingHandlerTest.php b/tests/Http/Client/CurlStreamingHandlerTest.php index bf5068990e..1f980f8bd5 100644 --- a/tests/Http/Client/CurlStreamingHandlerTest.php +++ b/tests/Http/Client/CurlStreamingHandlerTest.php @@ -37,7 +37,7 @@ class CurlStreamingHandlerTest extends TestCase public function testAmbiguousHostsAreRejectedBeforeConnecting(string $url, array $headers, string $message): void { $this->expectException(ConnectionException::class); - $this->expectExceptionMessage($message); + $this->expectExceptionMessageIsOrContains($message); (new Factory)->withOptions(['stream' => true])->withHeaders($headers)->get($url); } diff --git a/tests/Http/HttpClientStreamingTest.php b/tests/Http/HttpClientStreamingTest.php index 168660ac58..8926994347 100644 --- a/tests/Http/HttpClientStreamingTest.php +++ b/tests/Http/HttpClientStreamingTest.php @@ -522,7 +522,7 @@ public function testSilentHeadersUseTheIdleTimeoutWhenTheTotalTimeoutIsDisabled( $this->withStreamingServer('silent-headers', function (string $address): void { try { $this->expectException(ConnectionException::class); - $this->expectExceptionMessage('The streaming request timed out before response headers.'); + $this->expectExceptionMessageIs('The streaming request timed out before response headers.'); (new Factory)->timeout(0)->withOptions(['stream' => true, 'read_timeout' => 0.2])->get('http://' . $address); } finally { @@ -549,7 +549,7 @@ public function testHeaderProgressDoesNotResetTheTotalHeaderDeadline(): void { $this->withStreamingServer('trickle-headers', function (string $address): void { $this->expectException(ConnectionException::class); - $this->expectExceptionMessage('The streaming request timed out before response headers.'); + $this->expectExceptionMessageIs('The streaming request timed out before response headers.'); (new Factory)->timeout(0.4)->withOptions(['stream' => true, 'read_timeout' => 0.4])->get('http://' . $address); }); diff --git a/tests/Inertia/Commands/StopSsrTest.php b/tests/Inertia/Commands/StopSsrTest.php index c9a5ff7516..bcbff8e15c 100644 --- a/tests/Inertia/Commands/StopSsrTest.php +++ b/tests/Inertia/Commands/StopSsrTest.php @@ -4,10 +4,13 @@ namespace Hypervel\Tests\Inertia\Commands; +use Hypervel\Http\Client\Destinations\DestinationPolicyException; +use Hypervel\Http\Client\Destinations\DisallowedDestinationException; use Hypervel\Http\Client\Request; use Hypervel\Inertia\Ssr\HttpGateway; use Hypervel\Support\Facades\Http; use Hypervel\Tests\Inertia\TestCase; +use PHPUnit\Framework\Attributes\TestWith; class StopSsrTest extends TestCase { @@ -26,10 +29,32 @@ protected function setUp(): void Http::preventStrayRequests(); } - public function testFailsWhenTheSsrServerIsUnhealthy(): void + public function testFailureWhenTheSsrServerIsNotRunning(): void { Http::fake([ - $this->healthUrl => Http::response(status: 500), + $this->healthUrl => Http::failedConnection('Connection refused'), + ]); + + $this->artisan('inertia:stop-ssr') + ->expectsOutput('Unable to connect to Inertia SSR server.') + ->assertExitCode(1); + } + + public function testSuccessWhenTheSsrServerIsNotRunningAndTheGracefulOptionIsUsed(): void + { + Http::fake([ + $this->healthUrl => Http::failedConnection('Connection refused'), + ]); + + $this->artisan('inertia:stop-ssr', ['--graceful' => true]) + ->expectsOutput('Inertia SSR server is not running.') + ->assertExitCode(0); + } + + public function testFailureWhenAnotherServiceRespondsOnTheSsrUrl(): void + { + Http::fake([ + $this->healthUrl => Http::response('Hello from another service', 404), ]); $this->artisan('inertia:stop-ssr') @@ -39,6 +64,51 @@ public function testFailsWhenTheSsrServerIsUnhealthy(): void Http::assertSentCount(1); } + public function testFailureWhenAnotherServiceRespondsOnTheSsrUrlAndTheGracefulOptionIsUsed(): void + { + Http::fake([ + $this->healthUrl => Http::response('Hello from another service', 404), + ]); + + $this->artisan('inertia:stop-ssr', ['--graceful' => true]) + ->expectsOutput('Unable to connect to Inertia SSR server.') + ->assertExitCode(1); + } + + public function testTheResponseBodyIsNotPrintedToTheConsole(): void + { + Http::fake([ + $this->healthUrl => Http::response('Hello from another service', 404), + ]); + + $this->expectOutputString(''); + + $this->artisan('inertia:stop-ssr') + ->doesntExpectOutputToContain('Hello from another service') + ->run(); + } + + #[TestWith([DisallowedDestinationException::class])] + #[TestWith([DestinationPolicyException::class])] + public function testHealthChecksFailWhenTheSsrDestinationIsBlocked(string $exceptionClass): void + { + Http::fake([ + $this->healthUrl => static fn (): never => throw new $exceptionClass('Blocked SSR destination.'), + ]); + + $this->artisan('inertia:check-ssr') + ->expectsOutput('Inertia SSR server is not running.') + ->assertExitCode(1); + + $this->artisan('inertia:stop-ssr') + ->expectsOutput('Unable to connect to Inertia SSR server.') + ->assertExitCode(1); + + $this->artisan('inertia:stop-ssr', ['--graceful' => true]) + ->expectsOutput('Unable to connect to Inertia SSR server.') + ->assertExitCode(1); + } + public function testSucceedsWhenTheSsrServerStops(): void { Http::fake([ diff --git a/tests/Inertia/Fixtures/PrecognitiveRequestMiddleware.php b/tests/Inertia/Fixtures/PrecognitiveRequestMiddleware.php new file mode 100644 index 0000000000..9204f0fc56 --- /dev/null +++ b/tests/Inertia/Fixtures/PrecognitiveRequestMiddleware.php @@ -0,0 +1,27 @@ +header('Precognition') === 'true') { + $request->attributes->set('precognitive', true); + } + + /** @var Response $response */ + $response = $next($request); + + return $response; + } +} diff --git a/tests/Inertia/Fixtures/StorePartialReloadsMiddleware.php b/tests/Inertia/Fixtures/StorePartialReloadsMiddleware.php new file mode 100644 index 0000000000..e31157a7df --- /dev/null +++ b/tests/Inertia/Fixtures/StorePartialReloadsMiddleware.php @@ -0,0 +1,20 @@ +assertTrue($kernel->hasMiddleware(EnsureGetOnRedirect::class)); } + public function testDeferredCallbacksMiddlewareWrapsTheGlobalMiddlewareStack(): void + { + $kernel = $this->app->make(HttpKernelContract::class); + + $this->assertTrue($kernel->hasMiddleware(EnsureDeferredCallbacksRun::class)); + $this->assertSame(EnsureDeferredCallbacksRun::class, $kernel->getGlobalMiddleware()[0]); + } + public function testGatewayContractResolvesTheConcreteWorkerInstance(): void { $this->assertSame( @@ -152,10 +161,14 @@ public function testRedirectMiddlewareRegistersThroughTheKernelContract(): void ->once() ->with(EnsureGetOnRedirect::class) ->andReturnSelf(); + $kernel->shouldReceive('prependMiddleware') + ->once() + ->with(EnsureDeferredCallbacksRun::class) + ->andReturnSelf(); $this->app->instance(HttpKernelContract::class, $kernel); (new InspectableInertiaServiceProvider($this->app)) - ->pushRedirectMiddlewareForTest(); + ->registerRedirectMiddlewareForTest(); } public function testRedirectResponseFromRateLimiterIsConvertedTo303(): void @@ -186,8 +199,8 @@ class InspectableInertiaServiceProvider extends InertiaServiceProvider /** * Register redirect middleware for inspection. */ - public function pushRedirectMiddlewareForTest(): void + public function registerRedirectMiddlewareForTest(): void { - $this->pushRedirectMiddleware(); + $this->registerRedirectMiddleware(); } } diff --git a/tests/Inertia/MiddlewareTest.php b/tests/Inertia/MiddlewareTest.php index 4d146fc350..d85278e257 100644 --- a/tests/Inertia/MiddlewareTest.php +++ b/tests/Inertia/MiddlewareTest.php @@ -4,11 +4,16 @@ namespace Hypervel\Tests\Inertia; +use Hypervel\Contracts\Http\Kernel as HttpKernelContract; use Hypervel\Filesystem\Filesystem; +use Hypervel\Http\JsonResponse; +use Hypervel\Http\RedirectResponse; use Hypervel\Http\Request; +use Hypervel\Http\Response as BaseResponse; use Hypervel\Inertia\AlwaysProp; use Hypervel\Inertia\Inertia; use Hypervel\Inertia\Middleware; +use Hypervel\Inertia\Response; use Hypervel\Inertia\Ssr\HttpGateway; use Hypervel\Inertia\Support\Header; use Hypervel\Routing\Route as RouteInstance; @@ -19,8 +24,11 @@ use Hypervel\Support\ViewErrorBag; use Hypervel\Tests\Inertia\Fixtures\CustomUrlResolverMiddleware; use Hypervel\Tests\Inertia\Fixtures\ExampleMiddleware; +use Hypervel\Tests\Inertia\Fixtures\PrecognitiveRequestMiddleware; use Hypervel\Tests\Inertia\Fixtures\SsrExceptMiddleware; +use Hypervel\Tests\Inertia\Fixtures\StorePartialReloadsMiddleware; use Hypervel\Tests\Inertia\Fixtures\WithAllErrorsMiddleware; +use Hypervel\Tests\Inertia\Fixtures\WithoutPreviousLocationMiddleware; use LogicException; use PHPUnit\Framework\Attributes\After; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -512,6 +520,193 @@ public function testFlashDataIsPreservedOnNonInertiaRedirect(): void ]); } + public function testInertiaVisitsAreStoredAsThePreviousUrlAndRoute(): void + { + config()->set('inertia.store_previous_url', true); + + $this->preparePreviousLocationEndpoints(); + + $this->get('/initial')->assertOk(); + + $this->get('/users?filter=active', [ + 'X-Inertia' => 'true', + 'X-Requested-With' => 'XMLHttpRequest', + ])->assertOk(); + + $this->assertPreviousLocation($this->baseUrl . '/users?filter=active', 'users.index'); + } + + public function testInertiaVisitsAreNotStoredAsThePreviousUrlAndRouteByDefault(): void + { + $this->preparePreviousLocationEndpoints(); + + $this->get('/initial')->assertOk(); + + $this->get('/users', [ + 'X-Inertia' => 'true', + 'X-Requested-With' => 'XMLHttpRequest', + ])->assertOk(); + + $this->assertPreviousLocation($this->baseUrl . '/initial', 'initial'); + } + + public function testEmptyInertiaVisitsRedirectToThePreviousLocationWithoutAReferrer(): void + { + config()->set('inertia.store_previous_url', true); + + $this->preparePreviousLocationEndpoints(); + + Route::middleware([StartSession::class, Middleware::class]) + ->get('/empty', fn (): BaseResponse => response('')); + + $this->get('/initial')->assertOk(); + + $this->get('/empty', [ + 'X-Inertia' => 'true', + 'X-Requested-With' => 'XMLHttpRequest', + ])->assertRedirect('/initial'); + } + + public function testInertiaPrefetchVisitsAreNotStoredAsThePreviousUrlAndRoute(): void + { + config()->set('inertia.store_previous_url', true); + + $this->preparePreviousLocationEndpoints(); + + $this->get('/initial')->assertOk(); + + $this->get('/users', [ + 'X-Inertia' => 'true', + 'X-Requested-With' => 'XMLHttpRequest', + 'Purpose' => 'prefetch', + ])->assertOk(); + + $this->assertPreviousLocation($this->baseUrl . '/initial', 'initial'); + } + + public function testNonInertiaAjaxRequestsAreNotStoredAsThePreviousUrlAndRoute(): void + { + config()->set('inertia.store_previous_url', true); + + $this->preparePreviousLocationEndpoints(); + + $this->get('/initial')->assertOk(); + + $this->get('/users', [ + 'X-Requested-With' => 'XMLHttpRequest', + ])->assertOk(); + + $this->assertPreviousLocation($this->baseUrl . '/initial', 'initial'); + } + + public function testNonGetInertiaRequestsAreNotStoredAsThePreviousUrlAndRoute(): void + { + config()->set('inertia.store_previous_url', true); + + $this->preparePreviousLocationEndpoints(); + + $this->get('/initial')->assertOk(); + + $this->post('/users', [], [ + 'X-Inertia' => 'true', + 'X-Requested-With' => 'XMLHttpRequest', + ])->assertOk(); + + $this->assertPreviousLocation($this->baseUrl . '/initial', 'initial'); + } + + public function testPrecognitiveInertiaRequestsAreNotStoredAsThePreviousUrlAndRoute(): void + { + config()->set('inertia.store_previous_url', true); + + $this->preparePreviousLocationEndpoints(); + + $this->get('/initial')->assertOk(); + + $this->get('/users', [ + 'X-Inertia' => 'true', + 'X-Requested-With' => 'XMLHttpRequest', + 'Precognition' => 'true', + ])->assertSuccessful(); + + $this->assertPreviousLocation($this->baseUrl . '/initial', 'initial'); + } + + public function testPartialReloadsAreNotStoredAsThePreviousUrlAndRoute(): void + { + config()->set('inertia.store_previous_url', true); + + $this->preparePreviousLocationEndpoints(); + + $this->get('/initial')->assertOk(); + + $this->get('/users', [ + 'X-Inertia' => 'true', + 'X-Requested-With' => 'XMLHttpRequest', + ])->assertOk(); + + $this->get('/users?page=2', [ + 'X-Inertia' => 'true', + 'X-Requested-With' => 'XMLHttpRequest', + 'X-Inertia-Partial-Component' => 'Users/Index', + 'X-Inertia-Partial-Data' => 'users', + ])->assertOk(); + + $this->assertPreviousLocation($this->baseUrl . '/users', 'users.index'); + } + + public function testPartialVisitsToAnotherComponentAreStoredAsThePreviousUrlAndRoute(): void + { + config()->set('inertia.store_previous_url', true); + + $this->preparePreviousLocationEndpoints(); + + $this->get('/initial')->assertOk(); + + $this->get('/users', [ + 'X-Inertia' => 'true', + 'X-Requested-With' => 'XMLHttpRequest', + 'X-Inertia-Partial-Component' => 'Dashboard', + 'X-Inertia-Partial-Data' => 'users', + ])->assertOk(); + + $this->assertPreviousLocation($this->baseUrl . '/users', 'users.index'); + } + + public function testPartialReloadsCanBeStoredAsThePreviousUrlAndRoute(): void + { + config()->set('inertia.store_previous_url', true); + + $this->preparePreviousLocationEndpoints(StorePartialReloadsMiddleware::class); + + $this->get('/initial')->assertOk(); + + $this->get('/users?page=2', [ + 'X-Inertia' => 'true', + 'X-Requested-With' => 'XMLHttpRequest', + 'X-Inertia-Partial-Component' => 'Users/Index', + 'X-Inertia-Partial-Data' => 'users', + ])->assertOk(); + + $this->assertPreviousLocation($this->baseUrl . '/users?page=2', 'users.index'); + } + + public function testStoringThePreviousUrlAndRouteCanBeDisabled(): void + { + config()->set('inertia.store_previous_url', true); + + $this->preparePreviousLocationEndpoints(WithoutPreviousLocationMiddleware::class); + + $this->get('/initial')->assertOk(); + + $this->get('/users', [ + 'X-Inertia' => 'true', + 'X-Requested-With' => 'XMLHttpRequest', + ])->assertOk(); + + $this->assertPreviousLocation($this->baseUrl . '/initial', 'initial'); + } + public function testRedirectWithHashFragmentReturns409ForInertiaRequests(): void { Route::middleware([StartSession::class, Middleware::class])->get('/action', function () { @@ -582,6 +777,137 @@ public function testRedirectWithHashFragmentIsNotInterceptedForPrefetchRequests( $response->assertRedirect($this->baseUrl . '/article#section'); } + public function testDeferredCallbacksRunWhenAFragmentRedirectIsReturned(): void + { + $called = false; + + Route::middleware([StartSession::class, Middleware::class])->post('/action', function () use (&$called): RedirectResponse { + defer(function () use (&$called): void { + $called = true; + }); + + return redirect('/article#section'); + }); + + $response = $this->post('/action', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertStatus(409); + $response->assertHeader('X-Inertia-Redirect', $this->baseUrl . '/article#section'); + $this->assertTrue($called); + } + + public function testDeferredCallbacksRunWhenALocationVisitIsReturned(): void + { + $called = false; + + Route::middleware([StartSession::class, Middleware::class])->post('/action', function () use (&$called): SymfonyResponse { + defer(function () use (&$called): void { + $called = true; + }); + + return Inertia::location('https://inertiajs.com'); + }); + + $response = $this->post('/action', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertStatus(409); + $response->assertHeader('X-Inertia-Location', 'https://inertiajs.com'); + $this->assertTrue($called); + } + + public function testDeferredCallbacksRunWhenTheMiddlewareIsRegisteredGlobally(): void + { + $called = false; + + $kernel = $this->app->make(HttpKernelContract::class); + $kernel->pushMiddleware(Middleware::class); + + Route::middleware(StartSession::class)->post('/action', function () use (&$called): RedirectResponse { + defer(function () use (&$called): void { + $called = true; + }); + + return redirect('/article#section'); + }); + + $response = $this->post('/action', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertStatus(409); + $this->assertTrue($called); + } + + public function testDeferredCallbacksAreSkippedWhenAControlHeaderIsSetOnAFailedResponse(): void + { + $called = false; + + Route::middleware([StartSession::class, Middleware::class])->post('/action', function () use (&$called): BaseResponse { + defer(function () use (&$called): void { + $called = true; + }); + + return response('', 500, ['X-Inertia-Redirect' => '/article#section']); + }); + + $response = $this->post('/action', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertStatus(500); + $this->assertFalse($called); + } + + public function testDeferredCallbacksAreSkippedOnAVersionMismatchReload(): void + { + $called = false; + + $middleware = new ExampleMiddleware('1234'); + + Route::middleware(StartSession::class)->get('/', function (Request $request) use ($middleware, &$called): SymfonyResponse { + return $middleware->handle($request, function (Request $request) use (&$called): SymfonyResponse { + defer(function () use (&$called): void { + $called = true; + }); + + return Inertia::render('User/Edit')->toResponse($request); + }); + }); + + $response = $this->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => '4321', + ]); + + $response->assertStatus(409); + $response->assertHeader('X-Inertia-Location', $this->baseUrl); + $this->assertFalse($called); + } + + public function testDeferredCallbacksAreSkippedOnAFailedResponse(): void + { + $called = false; + + Route::middleware([StartSession::class, Middleware::class])->post('/action', function () use (&$called): never { + defer(function () use (&$called): void { + $called = true; + }); + + abort(500); + }); + + $response = $this->post('/action', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertStatus(500); + $this->assertFalse($called); + } + public function testMiddlewareRegistersSsrExceptPaths(): void { $middleware = new SsrExceptMiddleware; @@ -652,6 +978,8 @@ public function testVersionCacheResetsAfterFlushState(): void } /** + * Register an Inertia endpoint handled by the given or an example middleware. + * * @param array $shared */ private function prepareMockEndpoint(int|string|null $version = null, array $shared = [], ?Middleware $middleware = null): RouteInstance @@ -666,4 +994,42 @@ private function prepareMockEndpoint(int|string|null $version = null, array $sha }); }); } + + /** + * Assert the previous URL and route stored in the session. + */ + private function assertPreviousLocation(string $url, string $route): void + { + $response = $this->post('/inspect-previous'); + + $response->assertJsonPath('url', $url); + $response->assertJsonPath('route', $route); + } + + /** + * Register the routes used by the previous location tests. + * + * @param class-string $middleware + */ + private function preparePreviousLocationEndpoints(string $middleware = Middleware::class): void + { + Route::middleware([StartSession::class, $middleware])->get('/initial', function (): BaseResponse { + return response('Initial page'); + })->name('initial'); + + Route::middleware([ + StartSession::class, + PrecognitiveRequestMiddleware::class, + $middleware, + ])->match(['GET', 'POST'], '/users', function (): Response { + return Inertia::render('Users/Index'); + })->name('users.index'); + + Route::middleware(StartSession::class)->post('/inspect-previous', function (Request $request): JsonResponse { + return response()->json([ + 'url' => $request->session()->previousUrl(), + 'route' => $request->session()->previousRoute(), + ]); + }); + } } diff --git a/tests/Inertia/PropsResolverTest.php b/tests/Inertia/PropsResolverTest.php index d685a52546..dbbf10b928 100644 --- a/tests/Inertia/PropsResolverTest.php +++ b/tests/Inertia/PropsResolverTest.php @@ -4,11 +4,14 @@ namespace Hypervel\Tests\Inertia; +use ArrayObject; +use DateTimeImmutable; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Http\Response as BaseResponse; use Hypervel\Inertia\DevTools\RequestRecorder; use Hypervel\Inertia\Inertia; +use Hypervel\Inertia\InertiaState; use Hypervel\Inertia\MergeProp; use Hypervel\Inertia\PropertyContext; use Hypervel\Inertia\ProvidesInertiaProperties; @@ -17,8 +20,11 @@ use Hypervel\Inertia\RenderContext; use Hypervel\Inertia\Response; use Hypervel\Inertia\ScrollProp; +use Hypervel\Tests\Inertia\Fixtures\Enums\UnitEnum; +use Hypervel\View\ViewException; use JsonSerializable; use RuntimeException; +use stdClass; class PropsResolverTest extends TestCase { @@ -1420,15 +1426,163 @@ public function jsonSerialize(): array $this->assertSame('deferred value', $page['props']['dto']['thing']); } + public function testBigIntegersInsidePlainObjectsAreWrappedWhenEnabled(): void + { + $page = $this->makePage(Request::create('/'), [ + 'object' => (object) ['id' => 900719925474099988], + 'dto' => new class implements JsonSerializable { + /** + * Get the JSON serializable representation of the object. + */ + public function jsonSerialize(): array + { + return ['id' => 900719925474099988]; + } + }, + ], preserveBigIntegers: true); + + $this->assertSame(['$bigint' => '900719925474099988'], $page['props']['object']->id); + $this->assertSame(['$bigint' => '900719925474099988'], $page['props']['dto']['id']); + } + + public function testBigIntegersInsideArbitraryObjectsAreWrappedWhenEnabled(): void + { + $money = new class { + public int $cents = 900719925474099988; + }; + + $page = $this->makePage(Request::create('/'), [ + 'money' => $money, + 'nested' => ['money' => $money], + 'wrapped' => (object) ['money' => $money], + 'collection' => collect(['id' => 900719925474099988]), + ], preserveBigIntegers: true); + + $marker = ['$bigint' => '900719925474099988']; + + $this->assertSame($marker, $page['props']['money']->cents); + $this->assertSame($marker, $page['props']['nested']['money']->cents); + $this->assertSame($marker, $page['props']['wrapped']->money->cents); + $this->assertSame($marker, $page['props']['collection']['id']); + + $money->cents = 900719925474099989; + + $page = $this->makePage(Request::create('/'), ['money' => $money], preserveBigIntegers: true); + + $this->assertSame(['$bigint' => '900719925474099989'], $page['props']['money']->cents); + } + + public function testSelfReferencingObjectsDoNotRecurseForever(): void + { + $cyclic = new stdClass; + $cyclic->id = 900719925474099988; + $cyclic->self = $cyclic; + + // The root view throws when json_encode rejects the cycle left in the page. + try { + $this->makePage(Request::create('/'), ['cyclic' => $cyclic], preserveBigIntegers: true); + $this->fail('Expected json_encode to reject the self-reference.'); + } catch (ViewException $exception) { + $this->assertStringContainsString('Recursion detected', $exception->getMessage()); + } + + $this->assertSame(['$bigint' => '900719925474099988'], InertiaState::current()->page['props']['cyclic']->id); + } + + public function testAnObjectSharedByTwoPropsIsWrappedInBoth(): void + { + $shared = (object) ['id' => 900719925474099988]; + + $page = $this->makePage(Request::create('/'), ['first' => $shared, 'second' => $shared], preserveBigIntegers: true); + + $marker = ['$bigint' => '900719925474099988']; + + $this->assertSame($marker, $page['props']['first']->id); + $this->assertSame($marker, $page['props']['second']->id); + } + + public function testInternalObjectsKeepTheShapeJsonEncodeGivesThem(): void + { + $page = $this->makePage(Request::create('/'), [ + 'when' => ['at' => new DateTimeImmutable('2020-01-01T00:00:00Z')], + 'extended' => new class('2020-01-01T00:00:00Z') extends DateTimeImmutable {}, + 'bag' => new class(['name' => 'John']) extends ArrayObject {}, + ], preserveBigIntegers: true); + + // Walking its properties would flatten it to an empty object. + $this->assertSame( + '{"at":{"date":"2020-01-01 00:00:00.000000","timezone_type":2,"timezone":"Z"}}', + json_encode($page['props']['when']) + ); + $this->assertSame( + '{"date":"2020-01-01 00:00:00.000000","timezone_type":2,"timezone":"Z"}', + json_encode($page['props']['extended']) + ); + $this->assertSame('{"name":"John"}', json_encode($page['props']['bag'])); + } + + public function testPureEnumsAreLeftForJsonEncodeToReject(): void + { + // Walking its properties would invent a JSON representation it does not have. + $this->expectException(ViewException::class); + $this->expectExceptionMessageIsOrContains('Non-backed enums have no default serialization'); + + $this->makePage(Request::create('/'), ['status' => UnitEnum::Index], preserveBigIntegers: true); + } + + public function testBigIntegersInsideNumericallyKeyedObjectsAreWrappedWithoutChangingTheShape(): void + { + $page = $this->makePage(Request::create('/'), [ + 'list' => (object) ['0' => 900719925474099988, '1' => -900719925474099988], + 'empty' => new stdClass, + ], preserveBigIntegers: true); + + $this->assertInstanceOf(stdClass::class, $page['props']['list']); + $this->assertSame( + '{"0":{"$bigint":"900719925474099988"},"1":{"$bigint":"-900719925474099988"}}', + json_encode($page['props']['list']) + ); + $this->assertSame('{}', json_encode($page['props']['empty'])); + } + + public function testBigIntegersAreWrappedWhenEnabled(): void + { + $page = $this->makePage(Request::create('/'), [ + 'safe' => 42, + 'boundary' => 9007199254740991, + 'negativeBoundary' => -9007199254740991, + 'big' => 900719925474099988, + 'negative' => -900719925474099988, + 'nested' => ['deep' => [900719925474099988, 2]], + ], preserveBigIntegers: true); + + $this->assertSame(42, $page['props']['safe']); + $this->assertSame(9007199254740991, $page['props']['boundary']); + $this->assertSame(-9007199254740991, $page['props']['negativeBoundary']); + $this->assertSame(['$bigint' => '900719925474099988'], $page['props']['big']); + $this->assertSame(['$bigint' => '-900719925474099988'], $page['props']['negative']); + $this->assertSame(['$bigint' => '900719925474099988'], $page['props']['nested']['deep'][0]); + $this->assertSame(2, $page['props']['nested']['deep'][1]); + } + + public function testBigIntegersAreNotWrappedWhenDisabled(): void + { + $page = $this->makePage(Request::create('/'), [ + 'big' => 900719925474099988, + ]); + + $this->assertSame(900719925474099988, $page['props']['big']); + } + /** * 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 + protected function makePage(Request $request, array $props, bool $preserveBigIntegers = false): array { - $response = new Response('TestComponent', [], $props, 'app', '123'); + $response = new Response('TestComponent', [], $props, 'app', '123', preserveBigIntegers: $preserveBigIntegers); $response = $response->toResponse($request); if ($response instanceof JsonResponse) { diff --git a/tests/Inertia/ResponseFactoryTest.php b/tests/Inertia/ResponseFactoryTest.php index 5a9998b258..289cb114e7 100644 --- a/tests/Inertia/ResponseFactoryTest.php +++ b/tests/Inertia/ResponseFactoryTest.php @@ -17,6 +17,7 @@ use Hypervel\Inertia\MergeProp; use Hypervel\Inertia\OnceProp; use Hypervel\Inertia\OptionalProp; +use Hypervel\Inertia\Response as InertiaResponse; use Hypervel\Inertia\ResponseFactory; use Hypervel\Inertia\ScrollMetadata; use Hypervel\Inertia\ScrollProp; @@ -559,7 +560,7 @@ public function testWillNotThrowExceptionIfComponentDoesNotExistWhenEnsuringIsDi config()->set('inertia.pages.ensure_pages_exist', false); $response = (new ResponseFactory)->render('foo'); - $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response); + $this->assertInstanceOf(InertiaResponse::class, $response); } public function testWillNotThrowExceptionIfPageExistenceSettingIsOmitted(): void @@ -571,7 +572,7 @@ public function testWillNotThrowExceptionIfPageExistenceSettingIsOmitted(): void $response = (new ResponseFactory)->render('foo'); - $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response); + $this->assertInstanceOf(InertiaResponse::class, $response); } public function testCanResolveComponentNameBeforeRendering(): void @@ -625,7 +626,7 @@ public function testResolvedComponentNameIsUsedForPageExistenceChecks(): void public function testRenderAcceptsBackedEnum(): void { $response = (new ResponseFactory)->render(StringBackedEnum::UsersIndex); - $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response); + $this->assertInstanceOf(InertiaResponse::class, $response); /** @phpstan-ignore-next-line */ $getComponent = fn () => $this->component; @@ -635,7 +636,7 @@ public function testRenderAcceptsBackedEnum(): void public function testRenderAcceptsUnitEnum(): void { $response = (new ResponseFactory)->render(UnitEnum::Index); - $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response); + $this->assertInstanceOf(InertiaResponse::class, $response); /** @phpstan-ignore-next-line */ $getComponent = fn () => $this->component; @@ -786,6 +787,93 @@ public function testRenderWithFlashIncludesFlashInPage(): void $this->assertNull(session('inertia.flash_data')); } + public function testBigIntegersInFlashDataAreWrappedWhenEnabled(): void + { + config(['inertia.preserve_big_integers' => true]); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->post('/flash-big-integer', function (): InertiaResponse { + return Inertia::flash('id', 900719925474099988) + ->flash('safe', 42) + ->render('User/Edit'); + }); + + $response = $this->post('/flash-big-integer', [], ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'flash' => [ + 'id' => ['$bigint' => '900719925474099988'], + 'safe' => 42, + ], + ]); + } + + public function testBigIntegersInFlashDataAreUntouchedWhenDisabled(): void + { + config(['inertia.preserve_big_integers' => false]); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->post('/flash-big-integer', function (): InertiaResponse { + return Inertia::flash('id', 900719925474099988)->render('User/Edit'); + }); + + $response = $this->post('/flash-big-integer', [], ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson(['flash' => ['id' => 900719925474099988]]); + } + + public function testBigIntegersCanBePreservedForASingleResponse(): void + { + config(['inertia.preserve_big_integers' => false]); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/one-page', function (): InertiaResponse { + return Inertia::render('User/Edit', ['id' => 900719925474099988])->preserveBigIntegers(); + }); + + $response = $this->get('/one-page', ['X-Inertia' => 'true']); + + $response->assertJson([ + 'props' => ['id' => ['$bigint' => '900719925474099988']], + 'preserveBigIntegers' => true, + ]); + } + + public function testASingleResponseCanOptOutOfPreservingBigIntegers(): void + { + config(['inertia.preserve_big_integers' => true]); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/opted-out', function (): InertiaResponse { + return Inertia::render('User/Edit', ['id' => 900719925474099988])->preserveBigIntegers(false); + }); + + $response = $this->get('/opted-out', ['X-Inertia' => 'true']); + + $response->assertJson(['props' => ['id' => 900719925474099988]]); + $response->assertJsonMissingPath('preserveBigIntegers'); + } + + public function testTheInitialPageTellsTheClientWhenBigIntegersMayBeWrapped(): void + { + config(['inertia.preserve_big_integers' => true]); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/root', function (): InertiaResponse { + return Inertia::render('User/Edit', ['id' => 900719925474099988]); + }); + + $this->get('/root')->assertSee('"preserveBigIntegers":true', false); + } + + public function testTheInitialPageIsUnmarkedWhenBigIntegersAreDisabled(): void + { + config(['inertia.preserve_big_integers' => false]); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/root', function (): InertiaResponse { + return Inertia::render('User/Edit', ['id' => 900719925474099988]); + }); + + $this->get('/root')->assertDontSee('preserveBigIntegers', false); + } + public function testRenderWithoutFlashDoesNotIncludeFlashKey(): void { Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/no-flash', function () { diff --git a/tests/Inertia/Testing/AssertableInertiaTest.php b/tests/Inertia/Testing/AssertableInertiaTest.php index b4633266f8..a7d46a0bcb 100644 --- a/tests/Inertia/Testing/AssertableInertiaTest.php +++ b/tests/Inertia/Testing/AssertableInertiaTest.php @@ -6,6 +6,7 @@ use Hypervel\Inertia\Inertia; use Hypervel\Inertia\Middleware; +use Hypervel\Inertia\Response; use Hypervel\Inertia\Support\Header; use Hypervel\Inertia\Testing\AssertableInertia; use Hypervel\Session\Middleware\StartSession; @@ -516,6 +517,33 @@ public function testTheFlashDataCanBeAsserted(): void }); } + public function testBigIntegersAreAssertedAsIntegers(): void + { + $response = $this->makeMockRequest( + fn (): Response => Inertia::render('foo', [ + 'order' => ['id' => 900719925474099988, 'lines' => [['reference' => -900719925474099988]]], + ])->flash('id', 900719925474099988)->preserveBigIntegers(), + StartSession::class + ); + + $response->assertInertia(fn (AssertableInertia $inertia): AssertableInertia => $inertia + ->where('order.id', 900719925474099988) + ->where('order.lines.0.reference', -900719925474099988) + ->hasFlash('id', 900719925474099988)); + + $this->assertSame(900719925474099988, $response->inertiaProps('order.id')); + } + + public function testBigIntegerMarkersBuiltByTheAppAreLeftAloneWithoutPreservingBigIntegers(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', ['id' => ['$bigint' => '900719925474099988']]) + ); + + $response->assertInertia(fn (AssertableInertia $inertia): AssertableInertia => $inertia + ->where('id', ['$bigint' => '900719925474099988'])); + } + public function testTheFlashAssertionFailsWhenKeyIsMissing(): void { $response = $this->makeMockRequest(Inertia::render('foo')); diff --git a/tests/Jwt/Providers/LcobucciTest.php b/tests/Jwt/Providers/LcobucciTest.php index 1e05754d43..4ad821191b 100644 --- a/tests/Jwt/Providers/LcobucciTest.php +++ b/tests/Jwt/Providers/LcobucciTest.php @@ -28,7 +28,7 @@ use Lcobucci\JWT\Validation\Constraint\SignedWith; use PHPUnit\Framework\Attributes\DataProvider; use SensitiveParameterValue; -use TypeError; +use Throwable; class LcobucciTest extends TestCase { @@ -337,7 +337,7 @@ public function testMalformedRegisteredDatesAreReportedAsInvalidTokens(string $c $this->fail('Expected the malformed registered date to be rejected.'); } catch (TokenInvalidException $exception) { $this->assertStringStartsWith('Could not decode token:', $exception->getMessage()); - $this->assertInstanceOf(TypeError::class, $exception->getPrevious()); + $this->assertInstanceOf(Throwable::class, $exception->getPrevious()); } } diff --git a/tests/Notifications/NotificationTransportSerializationTest.php b/tests/Notifications/NotificationTransportSerializationTest.php index 6111866896..2dc0b235d8 100644 --- a/tests/Notifications/NotificationTransportSerializationTest.php +++ b/tests/Notifications/NotificationTransportSerializationTest.php @@ -318,7 +318,7 @@ public function testClosedAndDetachedStreamsRetainTheirCapabilityFlags(string $o $this->assertSame([], $restored->getMetadata()); $this->assertNull($restored->getSize()); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream is detached'); + $this->expectExceptionMessageIs('Stream is detached'); $restored->getContents(); } finally { if (is_resource($resource)) { @@ -364,7 +364,7 @@ public function testUnsupportedBufferOriginsAndCapabilitiesAreRejectedWithoutCon try { $this->expectException(LogicException::class); - $this->expectExceptionMessage('Cannot serialize notification transport bodies outside writable memory buffers.'); + $this->expectExceptionMessageIs('Cannot serialize notification transport bodies outside writable memory buffers.'); serialize($event); } finally { $this->assertSame($position, $stream->tell()); diff --git a/tests/SlackNotificationChannel/NotificationSlackChannelTest.php b/tests/SlackNotificationChannel/NotificationSlackChannelTest.php index 87b87b6b5d..ea39b202b1 100644 --- a/tests/SlackNotificationChannel/NotificationSlackChannelTest.php +++ b/tests/SlackNotificationChannel/NotificationSlackChannelTest.php @@ -27,7 +27,7 @@ public function testCorrectPayloadIsSentToSlack(Notification $notification, arra $slackChannel = new SlackWebhookChannel($guzzleHttp); - $guzzleHttp->shouldReceive('post')->andReturnUsing(function (string $argUrl, array $argPayload) use ($payload) { + $guzzleHttp->shouldReceive('post')->andReturnUsing(function (string $argUrl, array $argPayload) use ($payload): Response { $this->assertSame('url', $argUrl); $this->assertEquals($payload, $argPayload); @@ -37,6 +37,9 @@ public function testCorrectPayloadIsSentToSlack(Notification $notification, arra $slackChannel->send(new SlackChannelTestNotifiable('url'), $notification); } + /** + * Provide legacy notifications and their webhook payloads. + */ public static function payloadDataProvider(): array { return [ @@ -51,6 +54,9 @@ public function testModernBlockKitPayloadIsSentToSlack(): void { $guzzleHttp = m::mock(Client::class); $notification = new class extends Notification { + /** + * Get the Slack representation of the notification. + */ public function toSlack(mixed $notifiable): SlackMessage { return (new SlackMessage)->text('Modern content'); @@ -131,6 +137,20 @@ public function testZeroAttachmentValuesArePreserved(): void ], $payload['json']['attachments'][0]); } + public function testAttachmentFieldTitlesThatNamePhpFunctionsAreNotCalled(): void + { + $message = (new LegacySlackMessage)->content('Content')->attachment(function (SlackAttachment $attachment): void { + $attachment->field('Date', '2026-10-08')->field('Count', '3'); + }); + + $payload = (new SlackWebhookChannel(new Client))->buildJsonPayload($message); + + $this->assertSame([ + ['title' => 'Date', 'value' => '2026-10-08', 'short' => true], + ['title' => 'Count', 'value' => '3', 'short' => true], + ], $payload['json']['attachments'][0]['fields']); + } + public function testDisabledWebhookDoesNotRequireASlackMessageMethod(): void { $http = m::mock(Client::class); @@ -153,6 +173,9 @@ public function testRawWebhookRequestOptionsArePreserved(): void $this->assertSame(['X-Notification' => 'slack'], $payload['headers']); } + /** + * Get a notification with an emoji icon and its payload. + */ protected static function getPayloadWithIcon(): array { return [ @@ -190,6 +213,9 @@ protected static function getPayloadWithIcon(): array ]; } + /** + * Get a notification with an image icon and its payload. + */ protected static function getPayloadWithImageIcon(): array { return [ @@ -224,6 +250,9 @@ protected static function getPayloadWithImageIcon(): array ]; } + /** + * Get a notification without optional fields and its payload. + */ protected static function getPayloadWithoutOptionalFields(): array { return [ @@ -250,6 +279,9 @@ protected static function getPayloadWithoutOptionalFields(): array ]; } + /** + * Get a notification using the attachment field builder and its payload. + */ protected static function getPayloadWithAttachmentFieldBuilder(): array { return [ @@ -285,13 +317,16 @@ protected static function getPayloadWithAttachmentFieldBuilder(): array class NotificationSlackChannelTestNotification extends Notification { + /** + * Get the Slack representation of the notification. + */ public function toSlack(mixed $notifiable): LegacySlackMessage { return (new LegacySlackMessage) ->from('Ghostbot', ':ghost:') ->to('#ghost-talk') ->content('Content') - ->attachment(function (SlackAttachment $attachment) { + ->attachment(function (SlackAttachment $attachment): void { $timestamp = CarbonImmutable::createFromTimestamp(1234567890); $attachment->title('Hypervel', 'https://hypervel.org') ->content('Attachment Content') @@ -310,6 +345,9 @@ public function toSlack(mixed $notifiable): LegacySlackMessage class NotificationSlackChannelTestNotificationWithImageIcon extends Notification { + /** + * Get the Slack representation of the notification. + */ public function toSlack(mixed $notifiable): LegacySlackMessage { return (new LegacySlackMessage) @@ -317,7 +355,7 @@ public function toSlack(mixed $notifiable): LegacySlackMessage ->image('http://example.com/image.png') ->to('#ghost-talk') ->content('Content') - ->attachment(function (SlackAttachment $attachment) { + ->attachment(function (SlackAttachment $attachment): void { $timestamp = CarbonImmutable::createFromTimestamp(1234567890); $attachment->title('Hypervel', 'https://hypervel.org') ->content('Attachment Content') @@ -335,11 +373,14 @@ public function toSlack(mixed $notifiable): LegacySlackMessage class NotificationSlackChannelWithoutOptionalFieldsTestNotification extends Notification { + /** + * Get the Slack representation of the notification. + */ public function toSlack(mixed $notifiable): LegacySlackMessage { return (new LegacySlackMessage) ->content('Content') - ->attachment(function (SlackAttachment $attachment) { + ->attachment(function (SlackAttachment $attachment): void { $attachment->title('Hypervel', 'https://hypervel.org') ->content('Attachment Content') ->fields([ @@ -351,16 +392,19 @@ public function toSlack(mixed $notifiable): LegacySlackMessage class NotificationSlackChannelWithAttachmentFieldBuilderTestNotification extends Notification { + /** + * Get the Slack representation of the notification. + */ public function toSlack(mixed $notifiable): LegacySlackMessage { return (new LegacySlackMessage) ->content('Content') - ->attachment(function (SlackAttachment $attachment) { + ->attachment(function (SlackAttachment $attachment): void { $attachment->title('Hypervel', 'https://hypervel.org') ->content('Attachment Content') ->field('Project', 'Hypervel') ->callbackId('attachment_callbackid') - ->field(function (SlackAttachmentField $attachmentField) { + ->field(function (SlackAttachmentField $attachmentField): void { $attachmentField ->title('Special powers') ->content('Zonda') diff --git a/tests/SlackNotificationChannel/NotificationTransportQueueTest.php b/tests/SlackNotificationChannel/NotificationTransportQueueTest.php index 2b78595011..ed216deac1 100644 --- a/tests/SlackNotificationChannel/NotificationTransportQueueTest.php +++ b/tests/SlackNotificationChannel/NotificationTransportQueueTest.php @@ -51,9 +51,8 @@ protected function getPackageProviders(Application $app): array /** * Use the real queue that serializes and executes listener jobs. - * @param mixed $app */ - protected function defineEnvironment($app): void + protected function defineEnvironment(Application $app): void { $app->make('config')->set('queue.default', 'sync'); } @@ -199,7 +198,7 @@ public function testExistingSymfonyMailFailureConversionRemainsUnchanged(): void $response = m::mock(SymfonyResponse::class); $original = new HttpTransportException('Mailer HTTP failure', $response); $manager = $this->app->make(ChannelManager::class); - $manager->extend('test-mail', fn () => new NotificationTransportFailingChannel($original)); + $manager->extend('test-mail', fn (): NotificationTransportFailingChannel => new NotificationTransportFailingChannel($original)); $events = $this->app->make(Dispatcher::class); $live = null; $events->listen(NotificationFailed::class, function (NotificationFailed $event) use (&$live): void { @@ -249,6 +248,9 @@ private function configureTransport(bool $webhook, string $kind): void class NotificationTransportQueuedListener implements ShouldQueue { + /** + * The events received through the queue. + */ public array $received = []; /** diff --git a/tests/SlackNotificationChannel/Slack/Feature/SlackChannelTest.php b/tests/SlackNotificationChannel/Slack/Feature/SlackChannelTest.php index 304d3159ca..fc7a5bb13e 100644 --- a/tests/SlackNotificationChannel/Slack/Feature/SlackChannelTest.php +++ b/tests/SlackNotificationChannel/Slack/Feature/SlackChannelTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\SlackNotificationChannel\Slack\Feature; +use GuzzleHttp\Promise\PromiseInterface; use Hypervel\Http\Client\ConnectionException; use Hypervel\Http\Client\Factory; use Hypervel\Http\Client\Request; @@ -28,7 +29,7 @@ public function testAnHttpFakeInterceptsAnAlreadyResolvedChannel(): void $response = $channel->send( new SlackChannelTestNotifiable(SlackRoute::make('#general', 'token')), - new SlackChannelTestNotification(fn (SlackMessage $message) => $message->text('Content')), + new SlackChannelTestNotification(fn (SlackMessage $message): SlackMessage => $message->text('Content')), ); $this->assertSame('123.456', $response->json('ts')); @@ -41,7 +42,7 @@ public function testItUsesTheNamedConnectionAndHttpDefaults(array $preset, int $ $http = new Factory; $http->registerConnection(SlackChannel::CONNECTION, $preset); $options = []; - $http->fake(function (Request $request, array $requestOptions) use (&$options, $http) { + $http->fake(function (Request $request, array $requestOptions) use (&$options, $http): PromiseInterface { $options = $requestOptions; return $http::response(['ok' => true]); @@ -49,7 +50,7 @@ public function testItUsesTheNamedConnectionAndHttpDefaults(array $preset, int $ (new SlackChannel($http))->send( new SlackChannelTestNotifiable(SlackRoute::make('#general', 'token')), - new SlackChannelTestNotification(fn (SlackMessage $message) => $message->text('Content')), + new SlackChannelTestNotification(fn (SlackMessage $message): SlackMessage => $message->text('Content')), ); $this->assertSame(10, $options['connect_timeout']); @@ -74,7 +75,7 @@ public function testSeparateDeliveriesKeepTheirOwnTokenAndPayload(): void foreach (['first', 'second'] as $workspace) { $this->slackChannel->send( new SlackChannelTestNotifiable(SlackRoute::make("#{$workspace}", "{$workspace}-token")), - new SlackChannelTestNotification(fn (SlackMessage $message) => $message->text($workspace)), + new SlackChannelTestNotification(fn (SlackMessage $message): SlackMessage => $message->text($workspace)), ); } @@ -97,7 +98,7 @@ public function testHttpFailuresUseTheHttpClientException(int $status): void $this->expectException(RequestException::class); - $this->sendNotification(fn (SlackMessage $message) => $message->text('Content')); + $this->sendNotification(fn (SlackMessage $message): SlackMessage => $message->text('Content')); } /** @@ -118,7 +119,7 @@ public function testConnectionFailuresUseTheHttpClientException(): void $this->expectException(ConnectionException::class); $this->expectExceptionMessageIsOrContains('Slack is unreachable'); - $this->sendNotification(fn (SlackMessage $message) => $message->text('Content')); + $this->sendNotification(fn (SlackMessage $message): SlackMessage => $message->text('Content')); } #[DataProvider('successfulResponses')] @@ -130,7 +131,7 @@ public function testSuccessfulResponsesOnlyRejectAnExplicitFalseOk(array|string $response = (new SlackChannel($http))->send( new SlackChannelTestNotifiable(SlackRoute::make('#general', 'token')), - new SlackChannelTestNotification(fn (SlackMessage $message) => $message->text('Content')), + new SlackChannelTestNotification(fn (SlackMessage $message): SlackMessage => $message->text('Content')), ); $this->assertTrue($response->successful()); @@ -150,7 +151,7 @@ public function testTheRouteNotificationForSlackMethodDescribesTheChannelUsingAS $this->slackChannel->send( new SlackChannelTestNotifiable('example-channel'), - new SlackChannelTestNotification(function (SlackMessage $message) { + new SlackChannelTestNotification(function (SlackMessage $message): void { $message->text('Content')->to('ignored-channel'); }), ); @@ -167,7 +168,7 @@ public function testTheRouteNotificationForSlackMethodDescribesTheChannelUsingAS $this->slackChannel->send( new SlackChannelTestNotifiable(SlackRoute::make('route-set-channel')), - new SlackChannelTestNotification(function (SlackMessage $message) { + new SlackChannelTestNotification(function (SlackMessage $message): void { $message->text('Content'); }), ); @@ -184,7 +185,7 @@ public function testTheRouteNotificationForSlackMethodDescribesTheChannelAndToke $this->slackChannel->send( new SlackChannelTestNotifiable(SlackRoute::make('route-set-channel', 'route-set-token')), - new SlackChannelTestNotification(function (SlackMessage $message) { + new SlackChannelTestNotification(function (SlackMessage $message): void { $message->text('Content'); }), ); @@ -201,7 +202,7 @@ public function testTheRouteNotificationForSlackMethodOnlyDescribesTheTokenUsing $this->slackChannel->send( new SlackChannelTestNotifiable(SlackRoute::make(null, 'route-set-token')), - new SlackChannelTestNotification(function (SlackMessage $message) { + new SlackChannelTestNotification(function (SlackMessage $message): void { $message->text('Content')->to('notification-channel'); }), ); @@ -212,14 +213,14 @@ public function testTheRouteNotificationForSlackMethodOnlyDescribesTheTokenUsing ], 'route-set-token'); } - public function testNoRouteUsesTheConfiguredChannelAndToken(): void + public function testTheRouteNotificationForSlackMethodDoesNotDescribeAnything(): void { config()->set('services.slack.notifications.bot_user_oauth_token', 'config-set-token'); config()->set('services.slack.notifications.channel', 'config-set-channel'); $this->slackChannel->send( new SlackChannelTestNotifiable, - new SlackChannelTestNotification(function (SlackMessage $message) { + new SlackChannelTestNotification(function (SlackMessage $message): void { $message->text('Content'); }), ); @@ -237,7 +238,7 @@ public function testEmptySlackRouteUsesTheConfiguredChannelAndToken(): void $this->slackChannel->send( new SlackChannelTestNotifiable(SlackRoute::make()), - new SlackChannelTestNotification(function (SlackMessage $message) { + new SlackChannelTestNotification(function (SlackMessage $message): void { $message->text('Content'); }), ); @@ -255,7 +256,7 @@ public function testItPrefersTheNotificationDefinedChannelOverTheConfigDefinedCh $this->slackChannel->send( new SlackChannelTestNotifiable, - new SlackChannelTestNotification(function (SlackMessage $message) { + new SlackChannelTestNotification(function (SlackMessage $message): void { $message->text('Content')->to('notification-channel'); }), ); @@ -275,7 +276,7 @@ public function testItThrowsAnExceptionWhenTheRouteNotificationForSlackMethodDoe $this->slackChannel->send( new SlackChannelTestNotifiable, - new SlackChannelTestNotification(function (SlackMessage $message) { + new SlackChannelTestNotification(function (SlackMessage $message): void { $message->text('Content'); }), ); @@ -288,7 +289,7 @@ public function testItThrowsAnExceptionWhenTheRouteNotificationForSlackMethodDoe $this->slackChannel->send( new SlackChannelTestNotifiable(SlackRoute::make('hypervel-channel')), - new SlackChannelTestNotification(function (SlackMessage $message) { + new SlackChannelTestNotification(function (SlackMessage $message): void { $message->text('Content'); }), ); diff --git a/tests/SlackNotificationChannel/Slack/Feature/SlackMessageTest.php b/tests/SlackNotificationChannel/Slack/Feature/SlackMessageTest.php index e77c55b9d1..97017ae29f 100644 --- a/tests/SlackNotificationChannel/Slack/Feature/SlackMessageTest.php +++ b/tests/SlackNotificationChannel/Slack/Feature/SlackMessageTest.php @@ -11,6 +11,9 @@ use Hypervel\Notifications\Slack\BlockKit\Blocks\SectionBlock; use Hypervel\Notifications\Slack\SlackChannel; use Hypervel\Notifications\Slack\SlackMessage; +use Hypervel\Notifications\Slack\SlackRoute; +use Hypervel\Tests\SlackNotificationChannel\Slack\Fixtures\SlackChannelTestNotifiable; +use Hypervel\Tests\SlackNotificationChannel\Slack\Fixtures\SlackChannelTestNotification; use Hypervel\Tests\SlackNotificationChannel\Slack\TestCase; use JsonException; use LogicException; @@ -18,41 +21,39 @@ class SlackMessageTest extends TestCase { - public function testExceptionWhenNoTextOrBlock(): void + public function testItThrowsAnExceptionWhenNoTextOrBlockWasProvided(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Slack messages must contain at least a text message or block.'); - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->to('foo'); }); } - public function testExceptionWhenTooManyBlocks(): void + public function testItThrowsAnExceptionWhenTooManyBlocksAreDefined(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Slack messages can only contain up to 50 blocks.'); - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { for ($i = 0; $i < 51; ++$i) { $message->dividerBlock(); } }); } - public function testSendBasicMessage(): void + public function testItSendsAVeryBasicMessage(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('This is a simple Web API text message. See https://api.slack.com/reference/messaging/payload for more information.'); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'text' => 'This is a simple Web API text message. See https://api.slack.com/reference/messaging/payload for more information.', ]); } - public function testExceptionWithInvalidToken(): void + public function testItFailsToSendAMessageWhenTheSlackAppIsNotConfiguredCorrectly(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessageIs('Slack API call failed with error [invalid_auth].'); @@ -62,60 +63,52 @@ public function testExceptionWithInvalidToken(): void $http->fake(['*' => $http::response(['ok' => false, 'error' => 'invalid_auth'])]); $this->slackChannel = new SlackChannel($http); - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('This is a simple Web API text message. See https://api.slack.com/reference/messaging/payload for more information.'); }); } - public function testSetDefaultChannelForMessage(): void + public function testItCanSetTheDefaultChannelForTheMessage(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); $message->to('#general'); - }, null); - - $this->assertNotificationSent([ + }, null)->assertNotificationSent([ 'channel' => '#general', 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', ]); } - public function testEmojiAsIconForMessage(): void + public function testItCanUseAnEmojiAsTheIconForTheMessage(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); $message->image('emoji-overrides-image-url-automatically-according-to-spec')->emoji(':ghost:'); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', 'icon_emoji' => ':ghost:', ]); } - public function testImageAsIconForMessage(): void + public function testItCanUseAnImageAsTheIconForTheMessage(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); $message->emoji('auto-clearing-as-to-prefer-image-since-its-called-after')->image('http://lorempixel.com/48/48'); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', 'icon_url' => 'http://lorempixel.com/48/48', ]); } - public function testCanIncludeMetadata(): void + public function testItCanIncludeMetadata(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); $message->metadata('task_created', ['id' => '11223', 'title' => 'Redesign Homepage']); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', 'metadata' => [ @@ -125,98 +118,84 @@ public function testCanIncludeMetadata(): void ]); } - public function testDisableSlackMarkdownParsing(): void + public function testItCanDisableSlackMarkdownParsing(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); $message->disableMarkdownParsing(); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', 'mrkdwn' => false, ]); } - public function testUnfurlLink(): void + public function testItCanUnfurlLinks(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); $message->unfurlLinks(); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', 'unfurl_links' => true, ]); } - public function testUnfurlMedia(): void + public function testItCanUnfurlMedia(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); $message->unfurlMedia(); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', 'unfurl_media' => true, ]); } - public function testCanReplyAsThread(): void + public function testItCanReplyAsThread(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); $message->threadTimestamp('123456.7890'); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', 'thread_ts' => '123456.7890', ]); } - public function testSendThreadedReplyAsBroadcastReference(): void + public function testItCanSendThreadedReplyAsBroadcastReference(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); $message->broadcastReply(true); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', 'reply_broadcast' => true, ]); } - public function testSetBotUserName(): void + public function testItCanSetTheBotUserName(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('See https://api.slack.com/methods/chat.postMessage for more information.'); $message->username('larabot'); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.', 'username' => 'larabot', ]); } - public function testContainsBothBlocksAndFallbackTextInNotifications(): void + public function testItContainsBothBlocksAndAFallbackTextUsedInNotificationsOnly(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->text('This is now a fallback text used in notifications. See https://api.slack.com/methods/chat.postMessage for more information.'); $message->dividerBlock(); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'text' => 'This is now a fallback text used in notifications. See https://api.slack.com/methods/chat.postMessage for more information.', 'blocks' => [ @@ -227,15 +206,13 @@ public function testContainsBothBlocksAndFallbackTextInNotifications(): void ]); } - public function testContainActionBlocks(): void + public function testItCanContainActionBlocks(): void { - $this->sendNotification(function (SlackMessage $message) { - $message->actionsBlock(function (ActionsBlock $actions) { + $this->sendNotification(function (SlackMessage $message): void { + $message->actionsBlock(function (ActionsBlock $actions): void { $actions->button('Cancel')->value('cancel')->id('button_1'); }); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'blocks' => [ [ @@ -256,15 +233,13 @@ public function testContainActionBlocks(): void ]); } - public function testContainContextBlocks(): void + public function testItCanContainContextBlocks(): void { - $this->sendNotification(function (SlackMessage $message) { - $message->contextBlock(function (ContextBlock $context) { + $this->sendNotification(function (SlackMessage $message): void { + $message->contextBlock(function (ContextBlock $context): void { $context->image('https://image.freepik.com/free-photo/red-drawing-pin_1156-445.jpg')->alt('images'); }); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'blocks' => [ [ @@ -281,13 +256,11 @@ public function testContainContextBlocks(): void ]); } - public function testContainDividerBlocks(): void + public function testItCanContainDividerBlocks(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->dividerBlock(); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'blocks' => [ [ @@ -297,13 +270,11 @@ public function testContainDividerBlocks(): void ]); } - public function testContainHeaderBlocks(): void + public function testItCanContainHeaderBlocks(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->headerBlock('Budget Performance'); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'blocks' => [ [ @@ -317,15 +288,13 @@ public function testContainHeaderBlocks(): void ]); } - public function testContainImageBlocks(): void + public function testItCanContainImageBlocks(): void { - $this->sendNotification(function (SlackMessage $message) { - $message->imageBlock('http://placekitten.com/500/500', function (ImageBlock $imageBlock) { + $this->sendNotification(function (SlackMessage $message): void { + $message->imageBlock('http://placekitten.com/500/500', function (ImageBlock $imageBlock): void { $imageBlock->alt('An incredibly cute kitten.'); }); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'blocks' => [ [ @@ -337,15 +306,13 @@ public function testContainImageBlocks(): void ]); } - public function testContainSectionBlocks(): void + public function testItCanContainSectionBlocks(): void { - $this->sendNotification(function (SlackMessage $message) { - $message->sectionBlock(function (SectionBlock $sectionBlock) { + $this->sendNotification(function (SlackMessage $message): void { + $message->sectionBlock(function (SectionBlock $sectionBlock): void { $sectionBlock->text('A message *with some bold text* and _some italicized text_.')->markdown(); }); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'blocks' => [ [ @@ -359,29 +326,27 @@ public function testContainSectionBlocks(): void ]); } - public function testAddBlocksConditionally(): void + public function testItCanAddBlocksConditionally(): void { - $this->sendNotification(function (SlackMessage $message) { - $message->when(true, function (SlackMessage $message) { - $message->sectionBlock(function (SectionBlock $sectionBlock) { + $this->sendNotification(function (SlackMessage $message): void { + $message->when(true, function (SlackMessage $message): void { + $message->sectionBlock(function (SectionBlock $sectionBlock): void { $sectionBlock->text('I *will* be included.')->markdown(); }); - })->when(false, function (SlackMessage $message) { - $message->sectionBlock(function (SectionBlock $sectionBlock) { + })->when(false, function (SlackMessage $message): void { + $message->sectionBlock(function (SectionBlock $sectionBlock): void { $sectionBlock->text("I *won't* be included.")->markdown(); }); - })->when(false, function (SlackMessage $message) { - $message->sectionBlock(function (SectionBlock $sectionBlock) { + })->when(false, function (SlackMessage $message): void { + $message->sectionBlock(function (SectionBlock $sectionBlock): void { $sectionBlock->text("I'm *not* included either...")->markdown(); }); - }, function (SlackMessage $message) { - $message->sectionBlock(function (SectionBlock $sectionBlock) { + }, function (SlackMessage $message): void { + $message->sectionBlock(function (SectionBlock $sectionBlock): void { $sectionBlock->text('But I *will* be included!')->markdown(); }); }); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'blocks' => [ [ @@ -402,17 +367,15 @@ public function testAddBlocksConditionally(): void ]); } - public function testBlocksInTheOrder(): void + public function testItSubmitsBlocksInTheOrderTheyWereDefined(): void { - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->headerBlock('Budget Performance'); - $message->sectionBlock(function (SectionBlock $sectionBlock) { + $message->sectionBlock(function (SectionBlock $sectionBlock): void { $sectionBlock->text('A message *with some bold text* and _some italicized text_.')->markdown(); }); $message->headerBlock('Market Performance'); - }); - - $this->assertNotificationSent([ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'blocks' => [ [ @@ -440,42 +403,9 @@ public function testBlocksInTheOrder(): void ]); } - public function testCopiedBlockKitTemplate(): void + public function testItCanUseCopiedBlockKitTemplate(): void { - $payload = [ - 'channel' => '#ghost-talk', - 'blocks' => [ - [ - 'type' => 'header', - 'text' => [ - 'type' => 'plain_text', - 'text' => 'This is a header block', - 'emoji' => true, - ], - ], - [ - 'type' => 'context', - 'elements' => [ - [ - 'type' => 'image', - 'image_url' => 'https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg', - 'alt_text' => 'cute cat', - ], - [ - 'type' => 'mrkdwn', - 'text' => '*Cat* has approved this message.', - ], - ], - ], - [ - 'type' => 'image', - 'image_url' => 'https://assets3.thrillist.com/v1/image/1682388/size/tl-horizontal_main.jpg', - 'alt_text' => 'delicious tacos', - ], - ], - ]; - - $this->sendNotification(function (SlackMessage $message) { + $this->sendNotification(function (SlackMessage $message): void { $message->usingBlockKitTemplate(<<<'JSON' { "blocks": [ @@ -509,14 +439,7 @@ public function testCopiedBlockKitTemplate(): void ] } JSON); - }); - - $this->assertNotificationSent($payload); - } - - public function testCombinedBlockKitTemplateAndBlockContractInOrder(): void - { - $payload = [ + })->assertNotificationSent([ 'channel' => '#ghost-talk', 'blocks' => [ [ @@ -528,7 +451,18 @@ public function testCombinedBlockKitTemplateAndBlockContractInOrder(): void ], ], [ - 'type' => 'divider', + 'type' => 'context', + 'elements' => [ + [ + 'type' => 'image', + 'image_url' => 'https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg', + 'alt_text' => 'cute cat', + ], + [ + 'type' => 'mrkdwn', + 'text' => '*Cat* has approved this message.', + ], + ], ], [ 'type' => 'image', @@ -536,9 +470,12 @@ public function testCombinedBlockKitTemplateAndBlockContractInOrder(): void 'alt_text' => 'delicious tacos', ], ], - ]; + ]); + } - $this->sendNotification(function (SlackMessage $message) { + public function testItCanCombinedBlockKitTemplateAndBlockContractInOrder(): void + { + $this->sendNotification(function (SlackMessage $message): void { $message->usingBlockKitTemplate(<<<'JSON' { "blocks": [ @@ -567,27 +504,53 @@ public function testCombinedBlockKitTemplateAndBlockContractInOrder(): void ] } JSON); - }); - - $this->assertNotificationSent($payload); + })->assertNotificationSent([ + 'channel' => '#ghost-talk', + 'blocks' => [ + [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'This is a header block', + 'emoji' => true, + ], + ], + [ + 'type' => 'divider', + ], + [ + 'type' => 'image', + 'image_url' => 'https://assets3.thrillist.com/v1/image/1682388/size/tl-horizontal_main.jpg', + 'alt_text' => 'delicious tacos', + ], + ], + ]); } - public function testCanReturnABlockKitBuilderUrl(): void + public function testItCanReturnAnBlockKitBuilderUrl(): void { - $message = (new SlackMessage) - ->username('hyperbot') - ->to('#ghost-talk') - ->headerBlock('Budget Performance') - ->sectionBlock(function (SectionBlock $sectionBlock) { - $sectionBlock->text('A message *with some bold text* and _some italicized text_.')->markdown(); - }); - - $expectedUrl = 'https://app.slack.com/block-kit-builder#' . rawurlencode( - '{"blocks":[{"type":"header","text":{"type":"plain_text","text":"Budget Performance"}},' - . '{"type":"section","text":{"type":"mrkdwn","text":"A message *with some bold text* and _some italicized text_."}}]}' + $message = (new SlackChannelTestNotification(function (SlackMessage $message): void { + $message + ->username('larabot') + ->to('#ghost-talk') + ->headerBlock('Budget Performance') + ->sectionBlock(function (SectionBlock $sectionBlock): void { + $sectionBlock->text('A message *with some bold text* and _some italicized text_.')->markdown(); + }); + }))->toSlack( + new SlackChannelTestNotifiable(new SlackRoute('#ghost-talk', 'fake-token')) ); - $this->assertSame($expectedUrl, $message->toBlockKitBuilderUrl()); + $returnedUrl = $message->toBlockKitBuilderUrl(); + $expectedUrl = 'https://app.slack.com/block-kit-builder#' + . rawurlencode('{"blocks":[{"type":"header","text":{"type":"plain_text","text":"Budget Performance"}},{"type":"section","text":{"type":"mrkdwn","text":"A message *with some bold text* and _some italicized text_."}}]}'); + + $this->assertStringNotContainsStringIgnoringCase('username', $returnedUrl); + $this->assertStringNotContainsStringIgnoringCase('larabot', $returnedUrl); + $this->assertStringNotContainsStringIgnoringCase('channel', $returnedUrl); + $this->assertStringNotContainsStringIgnoringCase('#ghost-talk', $returnedUrl); + + $this->assertSame($expectedUrl, $returnedUrl); } public function testBlockKitBuilderUrlReportsJsonEncodingFailures(): void diff --git a/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotifiable.php b/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotifiable.php index 8ec003aa9c..dc895eef47 100644 --- a/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotifiable.php +++ b/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotifiable.php @@ -6,19 +6,24 @@ use Hypervel\Notifications\Notifiable; use Hypervel\Notifications\Slack\SlackRoute; +use Psr\Http\Message\UriInterface; class SlackChannelTestNotifiable { use Notifiable; - protected SlackRoute|string|null $route; - - public function __construct(SlackRoute|string|null $route = null) - { - $this->route = $route; + /** + * Create a notifiable with the given Slack route. + */ + public function __construct( + protected SlackRoute|UriInterface|string|false|null $route = null + ) { } - public function routeNotificationForSlack(): SlackRoute|string|null + /** + * Get the notification routing information for the Slack channel. + */ + public function routeNotificationForSlack(): SlackRoute|UriInterface|string|false|null { return $this->route; } diff --git a/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotification.php b/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotification.php index 9215d06aa8..0cdfc73077 100644 --- a/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotification.php +++ b/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotification.php @@ -10,14 +10,23 @@ class SlackChannelTestNotification extends Notification { + /** + * The callback that builds the Slack message. + */ private Closure $callback; + /** + * Create a notification with the given message callback. + */ public function __construct(?Closure $callback = null) { - $this->callback = $callback ?? function () { + $this->callback = $callback ?? function (): void { }; } + /** + * Get the Slack representation of the notification. + */ public function toSlack(mixed $notifiable): SlackMessage { return tap(new SlackMessage, $this->callback); diff --git a/tests/SlackNotificationChannel/Slack/TestCase.php b/tests/SlackNotificationChannel/Slack/TestCase.php index b3d62b9d5f..65740d9a94 100644 --- a/tests/SlackNotificationChannel/Slack/TestCase.php +++ b/tests/SlackNotificationChannel/Slack/TestCase.php @@ -18,10 +18,16 @@ abstract class TestCase extends BaseTestCase { - protected SlackChannel $slackChannel; - + /** + * The HTTP client factory. + */ protected Factory $http; + /** + * The Slack Web API channel. + */ + protected SlackChannel $slackChannel; + /** * Get the package providers. */ @@ -64,7 +70,8 @@ protected function assertNotificationSent(array $payload, string $token = 'fake- return $request->url() === 'https://slack.com/api/chat.postMessage' && $request->method() === 'POST' && $request->data() === $payload - && $request->hasHeader('Authorization', "Bearer {$token}"); + && $request->hasHeader('Authorization', "Bearer {$token}") + && $request->hasHeader('Content-Type', 'application/json'); }); } } diff --git a/tests/SlackNotificationChannel/Slack/Unit/Blocks/ActionsBlockTest.php b/tests/SlackNotificationChannel/Slack/Unit/Blocks/ActionsBlockTest.php index 0555e5419a..d1c9c47e73 100644 --- a/tests/SlackNotificationChannel/Slack/Unit/Blocks/ActionsBlockTest.php +++ b/tests/SlackNotificationChannel/Slack/Unit/Blocks/ActionsBlockTest.php @@ -10,7 +10,7 @@ class ActionsBlockTest extends TestCase { - public function testArrayable(): void + public function testItIsArrayable(): void { $block = new ActionsBlock; $block->button('Example Button'); @@ -30,7 +30,7 @@ public function testArrayable(): void ], $block->toArray()); } - public function testRequiresAtLeastOneElement(): void + public function testItRequiresAtLeastOneElement(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('There must be at least one element in each actions block.'); @@ -39,7 +39,7 @@ public function testRequiresAtLeastOneElement(): void $block->toArray(); } - public function testDoesNotAllowMoreTwentyFiveElements(): void + public function testItDoesNotAllowMoreThanTwentyFiveElements(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('There is a maximum of 25 elements in each actions block.'); @@ -52,7 +52,7 @@ public function testDoesNotAllowMoreTwentyFiveElements(): void $block->toArray(); } - public function testCanManuallySpecifyBlockIdField(): void + public function testItCanManuallySpecifyTheBlockIdField(): void { $block = new ActionsBlock; $block->button('Example Button'); @@ -83,7 +83,7 @@ public function testZeroBlockIdIsPreserved(): void $this->assertSame('0', $block->toArray()['block_id']); } - public function testBlockIdCantExceedTwoFiveFiveCharacters(): void + public function testTheBlockIdFieldCannotExceed255Characters(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Maximum length for the block_id field is 255 characters.'); @@ -105,7 +105,7 @@ public function testBlockIdUsesTheSlackCharacterLimit(): void $this->assertSame($id, $block->toArray()['block_id']); } - public function testCanAddButtons(): void + public function testItCanAddButtons(): void { $block = new ActionsBlock; $block->button('Example Button'); @@ -135,7 +135,7 @@ public function testCanAddButtons(): void ], $block->toArray()); } - public function testCanAddSelects(): void + public function testItCanAddSelects(): void { $block = new ActionsBlock; $block->staticSelect('Example Select') diff --git a/tests/SlackNotificationChannel/Slack/Unit/Blocks/ContextBlockTest.php b/tests/SlackNotificationChannel/Slack/Unit/Blocks/ContextBlockTest.php index 1b453a6eda..f10f2e7b72 100644 --- a/tests/SlackNotificationChannel/Slack/Unit/Blocks/ContextBlockTest.php +++ b/tests/SlackNotificationChannel/Slack/Unit/Blocks/ContextBlockTest.php @@ -10,7 +10,7 @@ class ContextBlockTest extends TestCase { - public function testArrayable(): void + public function testItIsArrayable(): void { $block = new ContextBlock; $block->text('Location: 123 Main Street, New York, NY 10010'); @@ -26,7 +26,7 @@ public function testArrayable(): void ], $block->toArray()); } - public function testRequiresAtLeastOneElement(): void + public function testItRequiresAtLeastOneElement(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('There must be at least one element in each context block.'); @@ -35,7 +35,7 @@ public function testRequiresAtLeastOneElement(): void $block->toArray(); } - public function testNotAllowMoreThanTenElements(): void + public function testItDoesNotAllowMoreThanTenElements(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('There is a maximum of 10 elements in each context block.'); @@ -48,7 +48,7 @@ public function testNotAllowMoreThanTenElements(): void $block->toArray(); } - public function testCanManuallySpecifyBlockIdField(): void + public function testItCanManuallySpecifyTheBlockIdField(): void { $block = new ContextBlock; $block->text('Location: 123 Main Street, New York, NY 10010'); @@ -75,7 +75,7 @@ public function testZeroBlockIdIsPreserved(): void $this->assertSame('0', $block->toArray()['block_id']); } - public function testBlockIdCantExceedTwoFiveFiveCharacters(): void + public function testTheBlockIdFieldCannotExceed255Characters(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Maximum length for the block_id field is 255 characters.'); @@ -97,7 +97,7 @@ public function testBlockIdUsesTheSlackCharacterLimit(): void $this->assertSame($id, $block->toArray()['block_id']); } - public function testCanAddImageBlocks(): void + public function testItCanAddImageBlocks(): void { $block = new ContextBlock; $block->image('https://image.freepik.com/free-photo/red-drawing-pin_1156-445.jpg')->alt('images'); @@ -120,7 +120,7 @@ public function testCanAddImageBlocks(): void ], $block->toArray()); } - public function testCanAddTextBlocks(): void + public function testItCanAddTextBlocks(): void { $block = new ContextBlock; $block->text('Location: 123 Main Street, New York, NY 10010'); diff --git a/tests/SlackNotificationChannel/Slack/Unit/Blocks/DividerBlockTest.php b/tests/SlackNotificationChannel/Slack/Unit/Blocks/DividerBlockTest.php index 4798bf3ae4..c12d7087c9 100644 --- a/tests/SlackNotificationChannel/Slack/Unit/Blocks/DividerBlockTest.php +++ b/tests/SlackNotificationChannel/Slack/Unit/Blocks/DividerBlockTest.php @@ -10,7 +10,7 @@ class DividerBlockTest extends TestCase { - public function testArrayable(): void + public function testItIsArrayable(): void { $block = new DividerBlock; @@ -19,7 +19,7 @@ public function testArrayable(): void ], $block->toArray()); } - public function testCanManuallySpecifyBlockIdField(): void + public function testItCanManuallySpecifyTheBlockIdField(): void { $block = new DividerBlock; $block->id('divider1'); @@ -35,7 +35,7 @@ public function testZeroBlockIdIsPreserved(): void $this->assertSame('0', (new DividerBlock)->id('0')->toArray()['block_id']); } - public function testBlockIdCantExceedTwoFiveFiveCharacters(): void + public function testTheBlockIdFieldCannotExceed255Characters(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Maximum length for the block_id field is 255 characters.'); diff --git a/tests/SlackNotificationChannel/Slack/Unit/Blocks/HeaderBlockTest.php b/tests/SlackNotificationChannel/Slack/Unit/Blocks/HeaderBlockTest.php index 05a1bb0401..df45194e83 100644 --- a/tests/SlackNotificationChannel/Slack/Unit/Blocks/HeaderBlockTest.php +++ b/tests/SlackNotificationChannel/Slack/Unit/Blocks/HeaderBlockTest.php @@ -10,7 +10,7 @@ class HeaderBlockTest extends TestCase { - public function testArrayable(): void + public function testItIsArrayable(): void { $block = new HeaderBlock('Budget Performance'); @@ -23,7 +23,7 @@ public function testArrayable(): void ], $block->toArray()); } - public function testBlockIdCantExceedOneFiveZeroCharacters(): void + public function testTheTextHeadingCannotExceed150Characters(): void { $blockA = new HeaderBlock(str_repeat('a', 151)); $blockB = new HeaderBlock(str_repeat('b', 150)); @@ -45,7 +45,7 @@ public function testBlockIdCantExceedOneFiveZeroCharacters(): void ], $blockB->toArray()); } - public function testCanManuallySpecifyBlockIdField(): void + public function testItCanManuallySpecifyTheBlockIdField(): void { $block = new HeaderBlock('Budget Performance'); $block->id('header1'); @@ -65,7 +65,7 @@ public function testZeroBlockIdIsPreserved(): void $this->assertSame('0', (new HeaderBlock('Header'))->id('0')->toArray()['block_id']); } - public function testBlockIdCantExceedTwoFiveFiveCharacters(): void + public function testTheBlockIdFieldCannotExceed255Characters(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Maximum length for the block_id field is 255 characters.'); diff --git a/tests/SlackNotificationChannel/Slack/Unit/Blocks/ImageBlockTest.php b/tests/SlackNotificationChannel/Slack/Unit/Blocks/ImageBlockTest.php index c8e991882d..361b311929 100644 --- a/tests/SlackNotificationChannel/Slack/Unit/Blocks/ImageBlockTest.php +++ b/tests/SlackNotificationChannel/Slack/Unit/Blocks/ImageBlockTest.php @@ -10,7 +10,7 @@ class ImageBlockTest extends TestCase { - public function testArrayable(): void + public function testItIsArrayable(): void { $block = new ImageBlock('http://placekitten.com/500/500', 'An incredibly cute kitten.'); @@ -21,7 +21,7 @@ public function testArrayable(): void ], $block->toArray()); } - public function testUrlCantExceedThreeThousandCharacters(): void + public function testTheUrlCannotExceed3000Characters(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Maximum length for the url field is 3000 characters.'); @@ -29,7 +29,7 @@ public function testUrlCantExceedThreeThousandCharacters(): void new ImageBlock(str_repeat('a', 3001)); } - public function testAltTextIsRequired(): void + public function testTheAltTextIsRequired(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Alt text is required for an image block.'); @@ -39,7 +39,7 @@ public function testAltTextIsRequired(): void $block->toArray(); } - public function testAltTextCantExceedTwoThousandCharacters(): void + public function testTheAltTextCannotExceed2000Characters(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Maximum length for the alt text field is 2000 characters.'); @@ -58,7 +58,7 @@ public function testConstructorAltTextCantExceedTwoThousandCharacters(): void new ImageBlock('http://placekitten.com/500/500', str_repeat('a', 2001)); } - public function testCanHaveTitle(): void + public function testItCanHaveATitle(): void { $block = new ImageBlock('http://placekitten.com/500/500', 'An incredibly cute kitten.'); $block->title('This one is a cutesy kitten in a box.'); @@ -74,7 +74,7 @@ public function testCanHaveTitle(): void ], $block->toArray()); } - public function testTitleCantExceedTwoThousandCharacters(): void + public function testTheTitleFieldCannotExceed2000Characters(): void { $block = new ImageBlock('http://placekitten.com/500/500', 'An incredibly cute kitten.'); $block->title(str_repeat('a', 2001)); @@ -90,7 +90,7 @@ public function testTitleCantExceedTwoThousandCharacters(): void ], $block->toArray()); } - public function testCanManuallySpecifyBlockIdField(): void + public function testItCanManuallySpecifyTheBlockIdField(): void { $block = new ImageBlock('http://placekitten.com/500/500'); $block->alt('An incredibly cute kitten.'); @@ -111,7 +111,7 @@ public function testZeroBlockIdIsPreserved(): void $this->assertSame('0', $block->toArray()['block_id']); } - public function testBlockIdCantExceedTwoFiveFiveCharacters(): void + public function testTheBlockIdFieldCannotExceed255Characters(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Maximum length for the block_id field is 255 characters.'); diff --git a/tests/SlackNotificationChannel/Slack/Unit/Blocks/SectionBlockTest.php b/tests/SlackNotificationChannel/Slack/Unit/Blocks/SectionBlockTest.php index 0f57582ea4..37eab0db11 100644 --- a/tests/SlackNotificationChannel/Slack/Unit/Blocks/SectionBlockTest.php +++ b/tests/SlackNotificationChannel/Slack/Unit/Blocks/SectionBlockTest.php @@ -11,7 +11,7 @@ class SectionBlockTest extends TestCase { - public function testArrayable(): void + public function testItIsArrayable(): void { $block = new SectionBlock; $block->text('Location: 123 Main Street, New York, NY 10010'); @@ -25,17 +25,17 @@ public function testArrayable(): void ], $block->toArray()); } - public function testExceptionWithoutTextAndField(): void + public function testItThrowsAnExceptionWhenNoTextOrFieldWasProvided(): void { $this->expectException(LogicException::class); - $this->expectExceptionMessageIs('A section requires at least one block, or the text to be set.'); + $this->expectExceptionMessageIs('A section requires at least one field, or the text to be set.'); $block = new SectionBlock; $block->toArray(); } - public function testTextHasAtLeastOneCharacter(): void + public function testTheTextHasAMinimumLengthOfOneCharacter(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Text must be at least 1 character(s) long.'); @@ -46,7 +46,7 @@ public function testTextHasAtLeastOneCharacter(): void $block->toArray(); } - public function testTextCantExceedThreeThousandCharacters(): void + public function testTheTextCannotExceed3000Characters(): void { $block = new SectionBlock; $block->text(str_repeat('a', 3001)); @@ -60,7 +60,7 @@ public function testTextCantExceedThreeThousandCharacters(): void ], $block->toArray()); } - public function testTextCanBeCustomized(): void + public function testTheTextCanBeCustomized(): void { $block = new SectionBlock; $block->text('Location: 123 Main Street, New York, NY 10010')->markdown(); @@ -74,7 +74,7 @@ public function testTextCanBeCustomized(): void ], $block->toArray()); } - public function testNotAllowMoreThanTenFields(): void + public function testItDoesNotAllowMoreThanTenFields(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('There is a maximum of 10 fields in each section block.'); @@ -87,7 +87,7 @@ public function testNotAllowMoreThanTenFields(): void $block->toArray(); } - public function testFieldCantExceedTwoThousandCharacters(): void + public function testAFieldCannotExceed2000Characters(): void { $block = new SectionBlock; $block->field(str_repeat('a', 2001)); @@ -103,7 +103,7 @@ public function testFieldCantExceedTwoThousandCharacters(): void ], $block->toArray()); } - public function testFieldCanBeCustomized(): void + public function testAFieldCanBeCustomized(): void { $block = new SectionBlock; $block->field('Location: 123 Main Street, New York, NY 10010')->markdown(); @@ -119,7 +119,7 @@ public function testFieldCanBeCustomized(): void ], $block->toArray()); } - public function testCanManuallySpecifyBlockIdField(): void + public function testItCanManuallySpecifyTheBlockIdField(): void { $block = new SectionBlock; $block->text('Location: 123 Main Street, New York, NY 10010'); @@ -145,7 +145,7 @@ public function testZeroBlockIdIsPreservedAndEmptyFieldsRemainOmitted(): void $this->assertArrayNotHasKey('fields', $block->toArray()); } - public function testBlockIdCantExceedTwoFiveFiveCharacters(): void + public function testTheBlockIdFieldCannotExceed255Characters(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Maximum length for the block_id field is 255 characters.'); @@ -167,7 +167,7 @@ public function testBlockIdUsesTheSlackCharacterLimit(): void $this->assertSame($id, $block->toArray()['block_id']); } - public function testCanSpecifyAccesoryElement(): void + public function testItCanSpecifyAnAccessoryElement(): void { $block = new SectionBlock; $block->text('Location: 123 Main Street, New York, NY 10010'); diff --git a/tests/SlackNotificationChannel/Slack/Unit/Composites/ConfirmObjectTest.php b/tests/SlackNotificationChannel/Slack/Unit/Composites/ConfirmObjectTest.php index 157d872cca..00e47aaac3 100644 --- a/tests/SlackNotificationChannel/Slack/Unit/Composites/ConfirmObjectTest.php +++ b/tests/SlackNotificationChannel/Slack/Unit/Composites/ConfirmObjectTest.php @@ -9,7 +9,7 @@ class ConfirmObjectTest extends TestCase { - public function testArrayable(): void + public function testItIsArrayable(): void { $object = new ConfirmObject; @@ -33,7 +33,7 @@ public function testArrayable(): void ], $object->toArray()); } - public function testTitleIsCustomizable(): void + public function testTheTitleFieldIsCustomizable(): void { $object = new ConfirmObject; $object->title('This is a custom title.'); @@ -58,7 +58,7 @@ public function testTitleIsCustomizable(): void ], $object->toArray()); } - public function testTitleTruncatedOverOneHundredCharacters(): void + public function testTheTitleGetsTruncatedWhenItExceeds100Characters(): void { $object = new ConfirmObject; $object->title(str_repeat('a', 101)); @@ -83,7 +83,7 @@ public function testTitleTruncatedOverOneHundredCharacters(): void ], $object->toArray()); } - public function testTextIsCustomizable(): void + public function testTheTextFieldIsCustomizable(): void { $object = new ConfirmObject; $object->text('This is some custom text.'); @@ -108,7 +108,7 @@ public function testTextIsCustomizable(): void ], $object->toArray()); } - public function testTextTruncatedOverThreeHundredCharacters(): void + public function testTheTextGetsTruncatedWhenItExceeds300Characters(): void { $objectA = new ConfirmObject(str_repeat('a', 301)); @@ -154,7 +154,7 @@ public function testTextTruncatedOverThreeHundredCharacters(): void ], $objectB->toArray()); } - public function testConfirmIsCustomizable(): void + public function testTheConfirmFieldIsCustomizable(): void { $object = new ConfirmObject; $object->confirm('Custom confirmation button.'); @@ -179,7 +179,7 @@ public function testConfirmIsCustomizable(): void ], $object->toArray()); } - public function testConfirmTruncatedOverThirtyCharacters(): void + public function testTheConfirmFieldIsGetsTruncatedAfter30Characters(): void { $object = new ConfirmObject; $object->confirm(str_repeat('a', 31)); @@ -204,7 +204,7 @@ public function testConfirmTruncatedOverThirtyCharacters(): void ], $object->toArray()); } - public function testColorSchemeWithDanger(): void + public function testTheColorSchemeCanBeSetToDanger(): void { $object = new ConfirmObject; $object->danger(); @@ -230,7 +230,7 @@ public function testColorSchemeWithDanger(): void ], $object->toArray()); } - public function testDenyIsCustomizable(): void + public function testTheDenyFieldIsCustomizable(): void { $object = new ConfirmObject; $object->deny('Custom deny button.'); @@ -255,7 +255,7 @@ public function testDenyIsCustomizable(): void ], $object->toArray()); } - public function testDenyTruncatedOverThirtyCharacters(): void + public function testTheDenyFieldIsGetsTruncatedAfter30Characters(): void { $object = new ConfirmObject; $object->deny(str_repeat('a', 31)); diff --git a/tests/SlackNotificationChannel/Slack/Unit/Composites/PlainTextOnlyTextObjectTest.php b/tests/SlackNotificationChannel/Slack/Unit/Composites/PlainTextOnlyTextObjectTest.php index 6355efdc4b..883f1ac282 100644 --- a/tests/SlackNotificationChannel/Slack/Unit/Composites/PlainTextOnlyTextObjectTest.php +++ b/tests/SlackNotificationChannel/Slack/Unit/Composites/PlainTextOnlyTextObjectTest.php @@ -10,7 +10,7 @@ class PlainTextOnlyTextObjectTest extends TestCase { - public function testArrayable(): void + public function testItIsArrayable(): void { $object = new PlainTextOnlyTextObject('A message *with some bold text* and _some italicized text_.'); @@ -20,7 +20,7 @@ public function testArrayable(): void ], $object->toArray()); } - public function testTextHasAtLeastOneCharacter(): void + public function testTheTextHasAMinimumLengthOf1Character(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Text must be at least 1 character(s) long.'); @@ -28,7 +28,7 @@ public function testTextHasAtLeastOneCharacter(): void new PlainTextOnlyTextObject(''); } - public function testTextTruncatedOverThreeThousandCharacters(): void + public function testTheTextGetsTruncatedWhenItExceeds3000Characters(): void { $object = new PlainTextOnlyTextObject(str_repeat('a', 3001)); @@ -65,7 +65,7 @@ public function testMalformedOverLimitTextIsRejectedBeforeTruncation(): void new PlainTextOnlyTextObject(str_repeat('a', 3001) . "\xFF"); } - public function testEscapeEmojiColonFormat(): void + public function testItCanIndicateThatEmojisShouldBeEscapedIntoTheColonEmojiFormat(): void { $object = new PlainTextOnlyTextObject('Spooky time! 👻'); $object->emoji(); diff --git a/tests/SlackNotificationChannel/Slack/Unit/Composites/TextObjectTest.php b/tests/SlackNotificationChannel/Slack/Unit/Composites/TextObjectTest.php index b9187ab694..4e3451a734 100644 --- a/tests/SlackNotificationChannel/Slack/Unit/Composites/TextObjectTest.php +++ b/tests/SlackNotificationChannel/Slack/Unit/Composites/TextObjectTest.php @@ -10,7 +10,7 @@ class TextObjectTest extends TestCase { - public function testArrayable(): void + public function testItIsArrayable(): void { $object = new TextObject('A message *with some bold text* and _some italicized text_.'); @@ -20,7 +20,7 @@ public function testArrayable(): void ], $object->toArray()); } - public function testMarkdownTextField(): void + public function testItCanBeAMarkdownTextField(): void { $object = new TextObject('A message *with some bold text* and _some italicized text_.'); $object->markdown(); @@ -31,7 +31,7 @@ public function testMarkdownTextField(): void ], $object->toArray()); } - public function testTextHasAtLeastOneCharacter(): void + public function testTheTextHasAMinimumLengthOf1Character(): void { $this->expectException(LogicException::class); $this->expectExceptionMessageIs('Text must be at least 1 character(s) long.'); @@ -39,7 +39,7 @@ public function testTextHasAtLeastOneCharacter(): void new TextObject(''); } - public function testTextTruncatedOverThreeThousandCharacters(): void + public function testTheTextGetsTruncatedWhenItExceeds3000Characters(): void { $object = new TextObject(str_repeat('a', 3001)); @@ -49,7 +49,7 @@ public function testTextTruncatedOverThreeThousandCharacters(): void ], $object->toArray()); } - public function testEscapeEmojiColonFormat(): void + public function testItCanIndicateThatEmojisShouldBeEscapedIntoTheColonEmojiFormat(): void { $object = new TextObject('Spooky time! 👻'); $object->emoji(); @@ -61,7 +61,7 @@ public function testEscapeEmojiColonFormat(): void ], $object->toArray()); } - public function testEscapeEmojiColonFormatWhenMarkdown(): void + public function testItCannotIndicateThatEmojisShouldBeEscapedIntoTheColonEmojiFormatWhenUsingMarkdown(): void { $object = new TextObject('Spooky time! 👻'); $object->markdown()->emoji(); @@ -72,7 +72,7 @@ public function testEscapeEmojiColonFormatWhenMarkdown(): void ], $object->toArray()); } - public function testSkipClickableAnchors(): void + public function testItCanIndicateThatAutoConversionIntoClickableAnchorsShouldBeSkipped(): void { $object = new TextObject('A message *with some bold text* and _some italicized text_.'); $object->markdown()->verbatim(); @@ -84,7 +84,7 @@ public function testSkipClickableAnchors(): void ], $object->toArray()); } - public function testSkipClickableAnchorsWhenPlaintext(): void + public function testItCannotIndicateThatAutoConversionIntoClickableAnchorsShouldBeSkippedWhenUsingPlaintext(): void { $object = new TextObject('A message *with some bold text* and _some italicized text_.'); $object->verbatim(); diff --git a/tests/SlackNotificationChannel/Slack/Unit/Elements/ButtonElementTest.php b/tests/SlackNotificationChannel/Slack/Unit/Elements/ButtonElementTest.php index a9e9761caa..d5c5e7265a 100644 --- a/tests/SlackNotificationChannel/Slack/Unit/Elements/ButtonElementTest.php +++ b/tests/SlackNotificationChannel/Slack/Unit/Elements/ButtonElementTest.php @@ -12,7 +12,7 @@ class ButtonElementTest extends TestCase { - public function testArrayable(): void + public function testItIsArrayable(): void { $element = new ButtonElement('Click Me'); @@ -26,7 +26,7 @@ public function testArrayable(): void ], $element->toArray()); } - public function testTextLengthIsSeventyFiveCharacters(): void + public function testTheMaximumTextLengthIs75Characters(): void { $element = new ButtonElement(str_repeat('a', 250)); @@ -40,9 +40,9 @@ public function testTextLengthIsSeventyFiveCharacters(): void ], $element->toArray()); } - public function testTextCanBeCustomized(): void + public function testTheTextCanBeCustomized(): void { - $element = new ButtonElement('Click Me', function (PlainTextOnlyTextObject $textObject) { + $element = new ButtonElement('Click Me', function (PlainTextOnlyTextObject $textObject): void { $textObject->emoji(); }); @@ -57,7 +57,7 @@ public function testTextCanBeCustomized(): void ], $element->toArray()); } - public function testActionIdCanBeCustomized(): void + public function testTheActionIdCanBeCustomized(): void { $element = new ButtonElement('Click Me'); $element->id('custom_action_id'); @@ -72,7 +72,7 @@ public function testActionIdCanBeCustomized(): void ], $element->toArray()); } - public function testActionIdCantExceedTwoFiveFiveCharacters(): void + public function testTheActionIdCannotExceed255Characters(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageIs('Maximum length for the action_id field is 255 characters.'); @@ -101,7 +101,7 @@ public function testGeneratedActionIdsFallbackWhenTextCannotBeSlugged(): void $this->assertNotSame($firstId, $secondId); } - public function testCanHaveUrl(): void + public function testItCanHaveAnUrl(): void { $element = new ButtonElement('Click Me'); $element->url('https://hypervel.org'); @@ -117,7 +117,7 @@ public function testCanHaveUrl(): void ], $element->toArray()); } - public function testUrlCantExceedThreeThousandCharacters(): void + public function testTheUrlCannotExceed3000Characters(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageIs('Maximum length for the url field is 3000 characters.'); @@ -128,7 +128,7 @@ public function testUrlCantExceedThreeThousandCharacters(): void $element->toArray(); } - public function testCanHaveValue(): void + public function testItCanHaveAValue(): void { $element = new ButtonElement('Click Me'); $element->value('click_me_123'); @@ -173,7 +173,7 @@ public function testZeroValueAndAccessibilityLabelArePreserved(): void $this->assertSame('0', $payload['accessibility_label']); } - public function testValueCantExceedTwoThousandCharacters(): void + public function testTheValueCannotExceed2000Characters(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageIs('Maximum length for the value field is 2000 characters.'); @@ -184,7 +184,7 @@ public function testValueCantExceedTwoThousandCharacters(): void $element->toArray(); } - public function testCanHavePrimaryStyle(): void + public function testItCanHaveThePrimaryStyle(): void { $element = new ButtonElement('Click Me'); $element->primary(); @@ -200,7 +200,7 @@ public function testCanHavePrimaryStyle(): void ], $element->toArray()); } - public function testCanHaveDangerStyle(): void + public function testItCanHaveTheDangerStyle(): void { $element = new ButtonElement('Click Me'); $element->danger(); @@ -216,7 +216,7 @@ public function testCanHaveDangerStyle(): void ], $element->toArray()); } - public function testCanHaveConfirmableDialog(): void + public function testItCanHaveAConfirmationDialog(): void { $element = new ButtonElement('Click Me'); $element->confirm('This will do some thing.')->deny('Yikes!'); @@ -249,10 +249,10 @@ public function testCanHaveConfirmableDialog(): void ], $element->toArray()); } - public function testConfirmationWithMultipleOptions(): void + public function testItCanScopeTheConfirmationDialogAndSetMultipleOptions(): void { $element = new ButtonElement('Click Me'); - $element->confirm('This will do some thing.', function (ConfirmObject $dialog) { + $element->confirm('This will do some thing.', function (ConfirmObject $dialog): void { $dialog->deny('Yikes!'); $dialog->confirm('Woohoo!'); }); @@ -285,7 +285,7 @@ public function testConfirmationWithMultipleOptions(): void ], $element->toArray()); } - public function testCanHaveAccessibilityLabel(): void + public function testItCanHaveAnAccessibilityLabel(): void { $element = new ButtonElement('Click Me'); $element->accessibilityLabel('Click Me Button'); @@ -301,7 +301,7 @@ public function testCanHaveAccessibilityLabel(): void ], $element->toArray()); } - public function testAccessibilityLabelCantExceedSeventyFiveCharacters(): void + public function testTheAccessibilityLabelCannotExceed75Characters(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageIs('Maximum length for the accessibility label is 75 characters.'); diff --git a/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/SelectOptionTest.php b/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/SelectOptionTest.php index fc0ea5dd2a..cbfd63ec03 100644 --- a/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/SelectOptionTest.php +++ b/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/SelectOptionTest.php @@ -18,6 +18,9 @@ public function testOptionValuesArePreserved(Stringable|string|int|float|bool $v $this->assertSame($expected, (new SelectOption('Example', $value))->toArray()['value']); } + /** + * Provide option values and their serialized strings. + */ public static function validOptionValues(): iterable { yield 'string' => ['Example Value', 'Example Value']; @@ -27,6 +30,9 @@ public static function validOptionValues(): iterable yield 'punctuation' => ['!@#$%^&*()', '!@#$%^&*()']; yield 'non-latin' => ['你好', '你好']; yield 'stringable' => [new class implements Stringable { + /** + * Get the string value. + */ public function __toString(): string { return 'Stringable Value'; @@ -44,6 +50,9 @@ public function testEmptyOptionValuesAreRejected( new SelectOption('Example', $value); } + /** + * Provide option values that serialize to an empty string. + */ public static function invalidOptionValues(): iterable { yield 'empty' => ['']; diff --git a/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/UsersSelectElementTest.php b/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/UsersSelectElementTest.php index a067b21567..a5ce8d1443 100644 --- a/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/UsersSelectElementTest.php +++ b/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/UsersSelectElementTest.php @@ -35,19 +35,4 @@ public function testItGeneratesADeterministicActionIdFromText(): void $this->assertSame('users_select_example-user', $select->toArray()['action_id']); } - - public function testGeneratedActionIdRespectsTheSlackLimit(): void - { - $select = new UsersSelectElement(str_repeat('a', 248)); - - $this->assertSame(255, strlen($select->toArray()['action_id'])); - } - - public function testDirectConstructionWithoutTextGeneratesAnActionId(): void - { - $actionId = (new UsersSelectElement)->toArray()['action_id']; - - $this->assertStringStartsWith('users_select_', $actionId); - $this->assertNotSame('users_select_', $actionId); - } } diff --git a/tests/SlackNotificationChannel/SlackChannelServiceProviderTest.php b/tests/SlackNotificationChannel/SlackChannelServiceProviderTest.php index e2b0d3a8e7..f93e407625 100644 --- a/tests/SlackNotificationChannel/SlackChannelServiceProviderTest.php +++ b/tests/SlackNotificationChannel/SlackChannelServiceProviderTest.php @@ -4,10 +4,12 @@ namespace Hypervel\Tests\SlackNotificationChannel; +use GuzzleHttp\Client as HttpClient; use Hypervel\Config\Repository; use Hypervel\Foundation\Application; use Hypervel\Http\Client\Factory; use Hypervel\Notifications\ChannelManager; +use Hypervel\Notifications\Channels\SlackWebhookChannel; use Hypervel\Notifications\NotificationServiceProvider; use Hypervel\Notifications\Slack\SlackChannel; use Hypervel\Notifications\SlackChannelServiceProvider; @@ -46,6 +48,35 @@ public static function managerResolution(): array return [[false], [true]]; } + public function testTheWebhookChannelUsesBoundedHttpTimeouts(): void + { + $app = new Application; + Facade::setFacadeApplication($app); + (new SlackChannelServiceProvider($app))->register(); + + $client = (fn (): HttpClient => $this->http)->call($app->make(SlackWebhookChannel::class)); + + $this->assertSame(10, $client->getConfig('connect_timeout')); + $this->assertSame(30, $client->getConfig('timeout')); + } + + public function testApplicationsCanReplaceTheWebhookHttpClient(): void + { + $app = new Application; + Facade::setFacadeApplication($app); + (new SlackChannelServiceProvider($app))->register(); + + $client = new HttpClient; + $app->when(SlackWebhookChannel::class) + ->needs(HttpClient::class) + ->give(fn (): HttpClient => $client); + + $this->assertSame( + $client, + (fn (): HttpClient => $this->http)->call($app->make(SlackWebhookChannel::class)), + ); + } + public function testItRegistersTheSlackHttpConnection(): void { $http = new Factory; diff --git a/tests/SlackNotificationChannel/SlackNotificationRouterChannelTest.php b/tests/SlackNotificationChannel/SlackNotificationRouterChannelTest.php index 2fc8790a84..75879332ad 100644 --- a/tests/SlackNotificationChannel/SlackNotificationRouterChannelTest.php +++ b/tests/SlackNotificationChannel/SlackNotificationRouterChannelTest.php @@ -11,8 +11,8 @@ use Hypervel\Notifications\Channels\SlackWebhookChannel; use Hypervel\Notifications\Notification; use Hypervel\Notifications\Slack\SlackChannel as SlackWebApiChannel; -use Hypervel\Notifications\Slack\SlackRoute; use Hypervel\Notifications\SlackNotificationRouterChannel; +use Hypervel\Tests\SlackNotificationChannel\Slack\Fixtures\SlackChannelTestNotifiable; use Hypervel\Tests\SlackNotificationChannel\Slack\Fixtures\SlackChannelTestNotification; use Hypervel\Tests\TestCase; use Mockery as m; @@ -24,7 +24,7 @@ public function testItRoutesTheNotificationToTheWebhookChannelWhenTheNotifiableR $app = new Container; $webhook = m::mock(SlackWebhookChannel::class); $webApi = m::mock(SlackWebApiChannel::class); - $webhook->shouldReceive('send')->once()->withArgs(function ($notifiable, $notification) { + $webhook->shouldReceive('send')->once()->withArgs(function (SlackChannelTestNotifiable $notifiable, Notification $notification): bool { return $notifiable->routeNotificationFor('slack', $notification) === 'http://example.com'; })->andReturn(new Response); $webApi->shouldNotReceive('send'); @@ -33,7 +33,7 @@ public function testItRoutesTheNotificationToTheWebhookChannelWhenTheNotifiableR $channel = new SlackNotificationRouterChannel($app); - $channel->send(new SlackNotificationRouterTestNotifiable('http://example.com'), new SlackChannelTestNotification); + $channel->send(new SlackChannelTestNotifiable('http://example.com'), new SlackChannelTestNotification); } public function testItRoutesTheNotificationToTheWebhookChannelWhenTheNotifiableRouteIsAPsrUrlInstance(): void @@ -41,7 +41,7 @@ public function testItRoutesTheNotificationToTheWebhookChannelWhenTheNotifiableR $app = new Container; $webhook = m::mock(SlackWebhookChannel::class); $webApi = m::mock(SlackWebApiChannel::class); - $webhook->shouldReceive('send')->once()->withArgs(function ($notifiable, $notification) { + $webhook->shouldReceive('send')->once()->withArgs(function (SlackChannelTestNotifiable $notifiable, Notification $notification): bool { return $notifiable->routeNotificationFor('slack', $notification) instanceof Uri; })->andReturn(new Response); $webApi->shouldNotReceive('send'); @@ -50,7 +50,7 @@ public function testItRoutesTheNotificationToTheWebhookChannelWhenTheNotifiableR $channel = new SlackNotificationRouterChannel($app); - $channel->send(new SlackNotificationRouterTestNotifiable(new Uri('foo')), new SlackChannelTestNotification); + $channel->send(new SlackChannelTestNotifiable(new Uri('foo')), new SlackChannelTestNotification); } public function testItRoutesTheNotificationToTheWebApiChannelWhenTheNotifiableRouteIsNotAnUrl(): void @@ -59,8 +59,8 @@ public function testItRoutesTheNotificationToTheWebApiChannelWhenTheNotifiableRo $webhook = m::mock(SlackWebhookChannel::class); $webApi = m::mock(SlackWebApiChannel::class); $webhook->shouldNotReceive('send'); - $webApi->shouldReceive('send')->once()->withArgs(function ($notifiable, $notification) { - return $notifiable->routeNotificationFor('slack', $notification) instanceof SlackRoute; + $webApi->shouldReceive('send')->once()->withArgs(function (SlackChannelTestNotifiable $notifiable, Notification $notification): bool { + return $notifiable->routeNotificationFor('slack', $notification) === '#general'; })->andReturn($response = new HttpResponse(new Response)); $app->instance(SlackWebhookChannel::class, $webhook); $app->instance(SlackWebApiChannel::class, $webApi); @@ -68,7 +68,7 @@ public function testItRoutesTheNotificationToTheWebApiChannelWhenTheNotifiableRo $channel = new SlackNotificationRouterChannel($app); $this->assertSame($response, $channel->send( - new SlackNotificationRouterTestNotifiable(SlackRoute::make('#general')), + new SlackChannelTestNotifiable('#general'), new SlackChannelTestNotification, )); } @@ -86,27 +86,8 @@ public function testItStopsSendingWhenTheNotifiableRouteIsFalse(): void $channel = new SlackNotificationRouterChannel($app); $this->assertNull($channel->send( - new SlackNotificationRouterTestNotifiable(false), + new SlackChannelTestNotifiable(false), new SlackChannelTestNotification, )); } } - -class SlackNotificationRouterTestNotifiable -{ - /** - * Create a notifiable with the given Slack route. - */ - public function __construct( - private readonly mixed $route, - ) { - } - - /** - * Get the notification route. - */ - public function routeNotificationFor(string $driver, ?Notification $notification = null): mixed - { - return $this->route; - } -} diff --git a/tests/Telescope/Watchers/CacheWatcherTest.php b/tests/Telescope/Watchers/CacheWatcherTest.php index e0c9877ad9..4738893c22 100644 --- a/tests/Telescope/Watchers/CacheWatcherTest.php +++ b/tests/Telescope/Watchers/CacheWatcherTest.php @@ -4,13 +4,13 @@ namespace Hypervel\Tests\Telescope\Watchers; +use Hypervel\Contracts\Cache\Factory; use Hypervel\Contracts\Cache\Repository; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Telescope; use Hypervel\Telescope\Watchers\CacheWatcher; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Telescope\FeatureTestCase; -use ReflectionClass; #[WithConfig('telescope.watchers', [ CacheWatcher::class => [ @@ -26,22 +26,6 @@ ])] class CacheWatcherTest extends FeatureTestCase { - protected function setUp(): void - { - parent::setUp(); - - CacheWatcher::enableCacheEvents($this->app); - } - - public function testFlushStateDisablesCacheEvents(): void - { - $this->assertTrue($this->eventsAreEnabled()); - - CacheWatcher::flushState(); - - $this->assertFalse($this->eventsAreEnabled()); - } - public function testCacheWatcherRegistersMissedEntries(): void { $this->app->make(Repository::class)->get('empty-key'); @@ -147,8 +131,24 @@ public function testCacheWatcherSkipsRecordingIgnoredCacheKeys(): void $this->assertSame('laravel', $entry->content['value']); } - private function eventsAreEnabled(): bool + #[WithConfig('cache.stores.failover', ['driver' => 'failover', 'stores' => ['array']])] + public function testCacheWatcherRecordsFailoverStoreOperationsOnce(): void { - return (new ReflectionClass(CacheWatcher::class))->getStaticPropertyValue('eventsEnabled'); + // The failover repository leaves its events to the store it uses. + $this->app->make(Factory::class)->store('failover')->get('failover-key'); + + $entries = $this->loadTelescopeEntries(); + + $this->assertCount(1, $entries); + $this->assertSame('missed', $entries->first()->content['type']); + $this->assertSame('failover-key', $entries->first()->content['key']); + } + + #[WithConfig('cache.stores.quiet', ['driver' => 'array', 'events' => false])] + public function testCacheWatcherLeavesStoresWithoutEventsUnrecorded(): void + { + $this->app->make(Factory::class)->store('quiet')->put('quiet-key', 'laravel', 1); + + $this->assertCount(0, $this->loadTelescopeEntries()); } } diff --git a/tests/Telescope/Watchers/DisabledWatcherTest.php b/tests/Telescope/Watchers/DisabledWatcherTest.php index 597632c358..7171fbe60e 100644 --- a/tests/Telescope/Watchers/DisabledWatcherTest.php +++ b/tests/Telescope/Watchers/DisabledWatcherTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Contracts\Cache\Repository; use Hypervel\Di\Aop\AspectCollector; use Hypervel\Redis\RedisConfig; use Hypervel\Telescope\Aspects\GuzzleHttpClientAspect; @@ -18,24 +18,20 @@ class DisabledWatcherTest extends FeatureTestCase { - protected function defineEnvironment(ApplicationContract $app): void - { - parent::defineEnvironment($app); - - // Override the FeatureTestCase default so we can detect whether - // the watcher's enableCacheEvents() mutated this config. - $app->make('config')->set('cache.stores.array.events', false); - } - #[WithConfig('telescope.watchers', [ CacheWatcher::class => [ 'enabled' => false, 'hidden' => [], ], ])] - public function testDisabledCacheWatcherDoesNotEnableCacheEvents(): void + public function testDisabledCacheWatcherRecordsNoCacheEntries(): void { - $this->assertFalse(config()->boolean('cache.stores.array.events')); + $repository = $this->app->make(Repository::class); + + $repository->put('disabled-key', 'laravel', 1); + $repository->get('disabled-key'); + + $this->assertCount(0, $this->loadTelescopeEntries()); } #[WithConfig('telescope.watchers', [ @@ -76,7 +72,6 @@ public function testGloballyDisabledTelescopeRegistersStorageWithoutInstrumentat DatabaseEntriesRepository::class, $this->app->make(EntriesRepository::class), ); - $this->assertFalse(config('cache.stores.array.events')); $this->assertFalse( $this->app->make(RedisConfig::class) ->connectionConfig('foo')['events'], diff --git a/tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php b/tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php index a5963dfa08..37cb838b2c 100644 --- a/tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php +++ b/tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php @@ -6,11 +6,23 @@ use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\AssertionFailedError; +use RuntimeException; use Swoole\Coroutine; use Swoole\Coroutine\Channel; class TimeLimitFixture extends TestCase { + /** + * Record that the test was torn down. + */ + protected function tearDown(): void + { + $this->record('tearDown ' . $this->name()); + + parent::tearDown(); + } + public function testNonYieldingWorkIsAborted(): void { while (true); @@ -24,9 +36,67 @@ public function testSleepingWorkIsAborted(): void public function testPendingChildrenAreAborted(): void { - Coroutine::create(function (): void { - $this->assertFalse((new Channel)->pop()); - }); + $this->startPendingChild(); + } + + public function testSkippedTestWithPendingChildrenIsAborted(): void + { + $this->startPendingChild(); + + $this->markTestSkipped('The test skipped itself.'); + } + + public function testIncompleteTestWithPendingChildrenIsAborted(): void + { + $this->startPendingChild(); + + $this->markTestIncomplete('The test marked itself incomplete.'); + } + + public function testAssertionFailureIsReportedWhenChildrenReachTheTimeLimit(): void + { + $this->startPendingChild(); + + $this->fail('The original assertion failure.'); + } + + public function testErrorIsReportedWhenChildrenReachTheTimeLimit(): void + { + $this->startPendingChild(); + + throw new RuntimeException('The original error.'); + } + + public function testExpectedExceptionFailsWhenChildrenReachTheTimeLimit(): void + { + $this->expectException(RuntimeException::class); + $this->startPendingChild(); + + throw new RuntimeException('The expected exception.'); + } + + public function testExpectedAssertionFailureFailsWhenChildrenReachTheTimeLimit(): void + { + $this->expectException(AssertionFailedError::class); + $this->startPendingChild(); + + $this->fail('The expected assertion failure.'); + } + + public function testExpectedExceptionWaitsForItsChildren(): void + { + $this->expectException(RuntimeException::class); + $this->startCompletingChild(); + + throw new RuntimeException('The expected exception.'); + } + + public function testExpectedAssertionFailureWaitsForItsChildren(): void + { + $this->expectException(AssertionFailedError::class); + $this->startCompletingChild(); + + $this->fail('The expected assertion failure.'); } public function testChildrenCompleteNormally(): void @@ -49,4 +119,32 @@ public function testWorkWithoutTimeLimitCompletes(): void { $this->assertTrue(Coroutine::sleep(0.01)); } + + /** + * Start a child that waits until the time limit cancels it. + */ + protected function startPendingChild(): void + { + Coroutine::create(function (): void { + $this->assertFalse((new Channel)->pop()); + }); + } + + /** + * Start a child that finishes after the test method has returned or thrown. + */ + protected function startCompletingChild(): void + { + Coroutine::create(function (): void { + $this->record(Coroutine::sleep(0.05) ? 'child completed' : 'child cancelled'); + }); + } + + /** + * Record an event in the log the fixture's runner reads. + */ + protected function record(string $event): void + { + file_put_contents((string) getenv('TIME_LIMIT_FIXTURE_LOG'), $event . PHP_EOL, FILE_APPEND); + } } diff --git a/tests/Testing/PHPUnit/TimeLimitTest.php b/tests/Testing/PHPUnit/TimeLimitTest.php index 5d4bd3c9b1..581fff01aa 100644 --- a/tests/Testing/PHPUnit/TimeLimitTest.php +++ b/tests/Testing/PHPUnit/TimeLimitTest.php @@ -4,6 +4,8 @@ namespace Hypervel\Tests\Testing\PHPUnit; +use Hypervel\Filesystem\Filesystem; +use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Process\Process; @@ -12,6 +14,30 @@ class TimeLimitTest extends TestCase { protected bool $runTestsInCoroutine = false; + protected string $tempDirectory; + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->tempDirectory = ParallelTesting::tempDir('TimeLimitTest'); + (new Filesystem)->deleteDirectory($this->tempDirectory); + mkdir($this->tempDirectory, 0777, true); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->tempDirectory); + + parent::tearDown(); + } + #[DataProvider('timedOutTests')] public function testTimedOutTestsFailWithTheirIdentity(string $method): void { @@ -33,15 +59,59 @@ public static function timedOutTests(): array 'non-yielding' => ['testNonYieldingWorkIsAborted'], 'sleeping root' => ['testSleepingWorkIsAborted'], 'pending child after root exits' => ['testPendingChildrenAreAborted'], + 'pending child after a skip' => ['testSkippedTestWithPendingChildrenIsAborted'], + 'pending child after marking incomplete' => ['testIncompleteTestWithPendingChildrenIsAborted'], + ]; + } + + #[DataProvider('failedTests')] + public function testFailuresKeepTheirCauseWhenChildrenReachTheTimeLimit(string $method, array $messages): void + { + $process = $this->runFixture($method, 1); + $output = $process->getOutput() . $process->getErrorOutput(); + + $this->assertFalse($process->isSuccessful()); + $this->assertStringContainsString('TimeLimitFixture::' . $method, $output); + + foreach ($messages as $message) { + $this->assertStringContainsString($message, $output); + } + + $this->assertStringNotContainsString('This test was aborted', $output); + $this->assertContains('tearDown ' . $method, $this->fixtureEvents()); + } + + /** + * Provide tests that throw before their pending children reach the deadline. + */ + public static function failedTests(): array + { + $childrenStillRunning = 'Child coroutines were still running at the time limit after the test threw an expected exception.'; + + return [ + 'assertion failure' => ['testAssertionFailureIsReportedWhenChildrenReachTheTimeLimit', ['The original assertion failure.']], + 'error' => ['testErrorIsReportedWhenChildrenReachTheTimeLimit', ['RuntimeException: The original error.']], + 'expected exception' => [ + 'testExpectedExceptionFailsWhenChildrenReachTheTimeLimit', + [$childrenStillRunning, 'RuntimeException: The expected exception.'], + ], + 'expected assertion failure' => [ + 'testExpectedAssertionFailureFailsWhenChildrenReachTheTimeLimit', + [$childrenStillRunning, 'The expected assertion failure.'], + ], ]; } #[DataProvider('completedTests')] - public function testCompletedWorkDoesNotTimeOut(string $method, int $limit): void + public function testCompletedWorkDoesNotTimeOut(string $method, int $limit, array $events): void { $process = $this->runFixture($method, $limit); $this->assertTrue($process->isSuccessful(), $process->getOutput() . $process->getErrorOutput()); + + foreach ($events as $event) { + $this->assertContains($event, $this->fixtureEvents()); + } } /** @@ -50,8 +120,14 @@ public function testCompletedWorkDoesNotTimeOut(string $method, int $limit): voi public static function completedTests(): array { return [ - 'child completes after root exits' => ['testChildrenCompleteNormally', 1], - 'no time limit' => ['testWorkWithoutTimeLimitCompletes', 0], + 'child completes after root exits' => ['testChildrenCompleteNormally', 1, []], + 'no time limit' => ['testWorkWithoutTimeLimitCompletes', 0, []], + 'child completes after an expected exception' => ['testExpectedExceptionWaitsForItsChildren', 1, ['child completed']], + 'child completes after an expected assertion failure' => [ + 'testExpectedAssertionFailureWaitsForItsChildren', + 1, + ['child completed'], + ], ]; } @@ -71,10 +147,21 @@ protected function runFixture(string $method, int $limit): Process 'tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php', ], cwd: dirname(__DIR__, 3), + env: ['TIME_LIMIT_FIXTURE_LOG' => $this->tempDirectory . '/events.log'], timeout: 15, ); $process->run(); return $process; } + + /** + * Get the events the fixture recorded. + * + * @return list + */ + protected function fixtureEvents(): array + { + return file($this->tempDirectory . '/events.log', FILE_IGNORE_NEW_LINES); + } }