From f3582c0432127f6b688c0d44d07d6a915b1ac04c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:21:24 +0000 Subject: [PATCH 01/13] Reject unsupported WebSocket versions during the handshake laravel/reverb PR 406 closes the connection when a WebSocket handshake fails. Swoole already does this: it closes the connection after any non-101 response to an upgrade request, and an invalid key already gets a 400. The handshake never checked Sec-WebSocket-Version, though, so a client asking for an unsupported version got a 101 instead of RFC 6455's 426. The websocket-server handshake now rejects it after the key check with a 426 carrying Upgrade, Connection and Sec-WebSocket-Version 13, as Ratchet's negotiator does for upstream. Upstream's two handshake tests are ported to the Reverb integration ServerTest with their five-second client timeout, and ServerHandshakeTest covers the rejection before routing. Handshake helpers in the tests now send the version header a real client sends. Upstream reference: laravel/reverb main at 74c8c4082c. Validation: the changed test files, the WebSocketServer, Sentry, Foundation HTTP and Reverb unit and integration suites, formatting and PHPStan pass. --- src/websocket-server/src/Security.php | 2 ++ src/websocket-server/src/Server.php | 16 +++++++-- tests/Integration/Reverb/ServerTest.php | 36 +++++++++++++++++++ tests/Sentry/WebSocketRuntimeContextTest.php | 1 + tests/WebSocketServer/ServerHandshakeTest.php | 34 +++++++++++++++++- 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/websocket-server/src/Security.php b/src/websocket-server/src/Security.php index 798f40397c..f0863e047e 100644 --- a/src/websocket-server/src/Security.php +++ b/src/websocket-server/src/Security.php @@ -14,6 +14,8 @@ class Security public const string SEC_WEBSOCKET_KEY = 'sec-websocket-key'; + public const string SEC_WEBSOCKET_VERSION = 'sec-websocket-version'; + public const string SEC_WEBSOCKET_PROTOCOL = 'sec-websocket-protocol'; /** diff --git a/src/websocket-server/src/Server.php b/src/websocket-server/src/Server.php index 8fd6530e5c..92ac47ee53 100644 --- a/src/websocket-server/src/Server.php +++ b/src/websocket-server/src/Server.php @@ -48,6 +48,7 @@ use Swoole\WebSocket\Frame; use Swoole\WebSocket\Server as WebSocketServer; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpException; use Throwable; class Server implements BootstrapsForServer, OnHandshakeInterface, OnCloseInterface, OnMessageInterface @@ -98,8 +99,9 @@ public function bootstrapForServer(string $serverName): void * Handle the WebSocket handshake request. * * Converts the Swoole request to HttpFoundation, validates the WebSocket - * security key, dispatches through the Router for route matching and - * middleware execution, then builds the 101 Switching Protocols response. + * security key and version, dispatches through the Router for route + * matching and middleware execution, then builds the 101 Switching + * Protocols response. */ public function onHandshake(Request $request, SwooleResponse $response): void { @@ -135,13 +137,21 @@ public function onHandshake(Request $request, SwooleResponse $response): void $this->logger->debug(sprintf('WebSocket: fd[%d] start a handshake request.', $fd)); - // Validate sec-websocket-key before routing + // Validate sec-websocket-key and sec-websocket-version before routing $key = $httpRequest->headers->get(Security::SEC_WEBSOCKET_KEY); $security = $this->container->make(Security::class); if (! $key || $security->isInvalidSecurityKey($key)) { throw new WebSocketHandshakeException('sec-websocket-key is invalid!'); } + if ($httpRequest->headers->get(Security::SEC_WEBSOCKET_VERSION) !== Security::VERSION) { + throw new HttpException(Response::HTTP_UPGRADE_REQUIRED, 'sec-websocket-version is unsupported!', headers: [ + 'Upgrade' => 'websocket', + 'Connection' => 'Upgrade', + 'Sec-WebSocket-Version' => Security::VERSION, + ]); + } + // Route matching + middleware via Router. // dispatchToCallback() performs the full Router context lifecycle // (findRoute, context setup, RouteMatched event, middleware pipeline) diff --git a/tests/Integration/Reverb/ServerTest.php b/tests/Integration/Reverb/ServerTest.php index 3baf7710f4..f012ccb24d 100644 --- a/tests/Integration/Reverb/ServerTest.php +++ b/tests/Integration/Reverb/ServerTest.php @@ -4,6 +4,8 @@ namespace Hypervel\Tests\Integration\Reverb; +use Swoole\Coroutine\Http\Client; + /** * End-to-end integration tests for the Reverb WebSocket server. * @@ -41,6 +43,40 @@ public function testFailsToConnectWithInvalidAppKey(): void $client->close(); } + public function testRejectsAHandshakeWithAnInvalidWebsocketKey(): void + { + $client = new Client($this->getServerHost(), $this->getServerPort()); + $client->set(['timeout' => 5]); + $client->setHeaders([ + 'Connection' => 'Upgrade', + 'Upgrade' => 'websocket', + 'Sec-WebSocket-Key' => 'invalid-key', + 'Sec-WebSocket-Version' => '13', + ]); + $client->get('/app/' . $this->appKey); + + $this->assertSame(400, $client->getStatusCode()); + + $client->close(); + } + + public function testRejectsAHandshakeRequestingAnUnsupportedWebsocketVersion(): void + { + $client = new Client($this->getServerHost(), $this->getServerPort()); + $client->set(['timeout' => 5]); + $client->setHeaders([ + 'Connection' => 'Upgrade', + 'Upgrade' => 'websocket', + 'Sec-WebSocket-Key' => 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version' => '8', + ]); + $client->get('/app/' . $this->appKey); + + $this->assertSame(426, $client->getStatusCode()); + + $client->close(); + } + // ── Channel subscriptions ────────────────────────────────────────── public function testCanSubscribeToAPublicChannel(): void diff --git a/tests/Sentry/WebSocketRuntimeContextTest.php b/tests/Sentry/WebSocketRuntimeContextTest.php index eabe2dcea8..246c64fb06 100644 --- a/tests/Sentry/WebSocketRuntimeContextTest.php +++ b/tests/Sentry/WebSocketRuntimeContextTest.php @@ -191,6 +191,7 @@ private function handshakeRequest(bool $validSecurityKey = true): SwooleRequest $request->header = [ 'host' => 'example.com', Security::SEC_WEBSOCKET_KEY => $validSecurityKey ? 'dGhlIHNhbXBsZSBub25jZQ==' : 'invalid', + Security::SEC_WEBSOCKET_VERSION => Security::VERSION, ]; $request->get = []; $request->post = []; diff --git a/tests/WebSocketServer/ServerHandshakeTest.php b/tests/WebSocketServer/ServerHandshakeTest.php index d5decca08e..54c6a8dda9 100644 --- a/tests/WebSocketServer/ServerHandshakeTest.php +++ b/tests/WebSocketServer/ServerHandshakeTest.php @@ -322,6 +322,37 @@ public function testRejectedHandshakeReleasesConnectionContext(): void $this->assertArrayNotHasKey(42, WebSocketContext::getStorage()); } + public function testUnsupportedWebSocketVersionIsRejectedBeforeRouting(): void + { + $sentEvent = null; + $events = new Dispatcher; + $events->listen(ResponseSent::class, function (ResponseSent $event) use (&$sentEvent): void { + $sentEvent = $event; + }); + $container = $this->container($events); + $container->shouldReceive('make')->once()->with(Security::class)->andReturn(new Security); + $container->shouldReceive('make')->once()->with(SafeCaller::class)->andReturn(new SafeCaller($container)); + $container->shouldReceive('make')->once()->with(WebSocketExceptionHandler::class)->andReturn( + new WebSocketExceptionHandler(m::mock(StdoutLoggerInterface::class)->shouldIgnoreMissing()), + ); + $router = m::mock(Router::class); + $router->shouldNotReceive('dispatchToCallback'); + $nativeServer = m::mock(SwooleWebSocketServer::class); + $nativeServer->shouldNotReceive('isEstablished'); + + (new HandshakeLifecycleServer($container, $router, $nativeServer))->onHandshake( + $this->request(version: '8'), + $this->response(Response::HTTP_UPGRADE_REQUIRED, 'sec-websocket-version is unsupported!'), + ); + + $this->assertInstanceOf(ResponseSent::class, $sentEvent); + $this->assertSame('websocket', $sentEvent->response->headers->get('Upgrade')); + $this->assertSame('Upgrade', $sentEvent->response->headers->get('Connection')); + $this->assertSame(Security::VERSION, $sentEvent->response->headers->get('Sec-WebSocket-Version')); + $this->assertNull(FdCollector::get(42)); + $this->assertArrayNotHasKey(42, WebSocketContext::getStorage()); + } + public function testConnectionClosedDuringEmissionIsNotPublishedAfterward(): void { $container = $this->container(); @@ -485,7 +516,7 @@ protected function router(int $status, array $middleware = []): Router /** * Create a native handshake request. */ - protected function request(): SwooleRequest + protected function request(string $version = Security::VERSION): SwooleRequest { $request = m::mock(SwooleRequest::class); $request->fd = 42; @@ -496,6 +527,7 @@ protected function request(): SwooleRequest $request->header = [ 'host' => 'example.com', Security::SEC_WEBSOCKET_KEY => 'dGhlIHNhbXBsZSBub25jZQ==', + Security::SEC_WEBSOCKET_VERSION => $version, ]; $request->get = []; $request->post = []; From 7d85fdda0e909241de6993969541bf35f1e87035 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:21:32 +0000 Subject: [PATCH 02/13] Assert that unsupported client events never read channel members ClientEventTest's unsupported-message test expected hydratedConnections() never to be called, but the channel connection manager has no such method, so the expectation could never fail. laravel/reverb PR 407 asserts all() is never called instead, and the test now does the same. PR 407 converts upstream's tests from Mockery to Double. Hypervel keeps Mockery and doesn't port the exact call counts, which only constrain internal lookups. It also keeps the all() and find() stubs that PR 407 removed from the members-mode and none tests: Double fails on unused expectations, while Mockery's spy allows them, and the stubs are what let those tests' assertNothingReceived() checks catch a forwarded message. Upstream reference: laravel/reverb main at 74c8c4082c. Validation: the changed test file and the Reverb unit suite pass. --- tests/Reverb/Protocols/Pusher/ClientEventTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Reverb/Protocols/Pusher/ClientEventTest.php b/tests/Reverb/Protocols/Pusher/ClientEventTest.php index d32293f3ec..64cb622f6c 100644 --- a/tests/Reverb/Protocols/Pusher/ClientEventTest.php +++ b/tests/Reverb/Protocols/Pusher/ClientEventTest.php @@ -340,7 +340,7 @@ public function testFailsOnUnsupportedMessage(): void $connection = new FakeConnection; - $this->channelConnectionManager->shouldNotReceive('hydratedConnections'); + $this->channelConnectionManager->shouldNotReceive('all'); ClientEvent::handle( $connection, From 5310412d60b9ba01fb72df4dc3a3971102668cce Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:21:39 +0000 Subject: [PATCH 03/13] Reject Reverb API signatures outside the timestamp tolerance Signed HTTP API requests were accepted whatever their auth_timestamp, so a captured request could be replayed indefinitely. Following laravel/reverb commit 2f8a121813, the controller now rejects a request whose timestamp is missing or more than 600 seconds from the current time, after the signature itself is verified. Hypervel passes the verified query to the check instead of reading it from controller state. The signed-request test helpers take upstream's optional timestamp. ChannelsControllerTest ports the expired and future rejection test and adds a correctly signed request without a timestamp. Upstream reference: laravel/reverb main at 74c8c4082c. Validation: the changed test files, the Reverb unit and integration suites, formatting and PHPStan pass. --- .../Pusher/Http/Controllers/Controller.php | 21 +++++++++++++ .../Controllers/ChannelsControllerTest.php | 30 +++++++++++++++++++ tests/Reverb/ReverbTestCase.php | 9 ++++-- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php b/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php index 8d5510d7c3..f3cb818cd2 100644 --- a/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php +++ b/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php @@ -16,6 +16,11 @@ abstract class Controller { + /** + * The number of seconds either side of the current time a request signature remains valid. + */ + protected const int SIGNATURE_TOLERANCE = 600; + /** * Verify that the incoming request is valid. * @@ -88,6 +93,8 @@ protected function verifySignature(Request $request, Application $application, s if (! is_string($authSignature) || ! hash_equals($signature, $authSignature)) { throw new HttpException(401, 'Authentication signature invalid.'); } + + $this->verifySignatureTimestamp($query); } /** @@ -103,4 +110,18 @@ protected static function formatQueryParametersForVerification(array $params): s return "{$key}={$value}"; })->implode('&'); } + + /** + * Verify that the request signature has not expired. + * + * @throws HttpException + */ + protected function verifySignatureTimestamp(array $query): void + { + $timestamp = $query['auth_timestamp'] ?? null; + + if (! is_numeric($timestamp) || abs(time() - (int) $timestamp) > static::SIGNATURE_TOLERANCE) { + throw new HttpException(401, 'Authentication signature invalid.'); + } + } } diff --git a/tests/Reverb/Protocols/Pusher/Http/Controllers/ChannelsControllerTest.php b/tests/Reverb/Protocols/Pusher/Http/Controllers/ChannelsControllerTest.php index 226935e436..eb1e0431e7 100644 --- a/tests/Reverb/Protocols/Pusher/Http/Controllers/ChannelsControllerTest.php +++ b/tests/Reverb/Protocols/Pusher/Http/Controllers/ChannelsControllerTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Reverb\Protocols\Pusher\Http\Controllers; use Hypervel\Tests\Reverb\ReverbTestCase; +use PHPUnit\Framework\Attributes\DataProvider; class ChannelsControllerTest extends ReverbTestCase { @@ -23,6 +24,35 @@ public function testCanReturnAllChannelInformation(): void $this->assertSame(1, $body['channels']['presence-test-channel-two']['user_count']); } + #[DataProvider('timestampOffsetsOutsideTolerance')] + public function testRejectsRequestSignaturesOutsideTheTimestampTolerance(int $offset): void + { + $response = $this->signedRequest('channels', timestamp: time() + $offset); + + $response->assertStatus(401); + } + + /** + * Supply signature timestamp offsets outside the tolerance. + */ + public static function timestampOffsetsOutsideTolerance(): array + { + return [ + 'expired' => [-3600], + 'future' => [3600], + ]; + } + + public function testRejectsRequestSignaturesWithoutATimestamp(): void + { + $query = 'auth_key=reverb-key&auth_version=1.0'; + $signature = hash_hmac('sha256', "GET\n/apps/123456/channels\n{$query}", 'reverb-secret'); + + $response = $this->reverbGet("/apps/123456/channels?{$query}&auth_signature={$signature}"); + + $response->assertStatus(401); + } + public function testCanReturnFilteredChannelsByPrefix(): void { $this->subscribeConnection('test-channel-one'); diff --git a/tests/Reverb/ReverbTestCase.php b/tests/Reverb/ReverbTestCase.php index 59a27aa366..74a4680589 100644 --- a/tests/Reverb/ReverbTestCase.php +++ b/tests/Reverb/ReverbTestCase.php @@ -193,8 +193,9 @@ protected function signedRequest( string $appId = '123456', string $key = 'reverb-key', string $secret = 'reverb-secret', + ?int $timestamp = null, ): TestResponse { - $uri = $this->buildSignedUri('GET', $path, '', $appId, $key, $secret); + $uri = $this->buildSignedUri('GET', $path, '', $appId, $key, $secret, $timestamp); return $this->dispatchThroughReverbRouter( Request::create($uri, 'GET') @@ -213,10 +214,11 @@ protected function signedPostRequest( string $appId = '123456', string $key = 'reverb-key', string $secret = 'reverb-secret', + ?int $timestamp = null, ): TestResponse { $body = $data !== null ? json_encode($data) : ''; - $uri = $this->buildSignedUri('POST', $path, $body, $appId, $key, $secret); + $uri = $this->buildSignedUri('POST', $path, $body, $appId, $key, $secret, $timestamp); return $this->dispatchThroughReverbRouter( Request::create($uri, 'POST', server: [ @@ -283,8 +285,9 @@ private function buildSignedUri( string $appId, string $key, string $secret, + ?int $timestamp = null, ): string { - $timestamp = time(); + $timestamp ??= time(); // Separate existing query params from path $queryString = Str::contains($path, '?') ? Str::after($path, '?') : ''; From 1c1dab34a549acdc65ef259136c5f3b25ae05ffa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:21:51 +0000 Subject: [PATCH 04/13] Fix Reverb presence data for empty user info and failed gathers laravel/reverb PR 408 fixes presence channels when scaling is enabled. Most of it is already part of Hypervel's design: member events are routed as internal events, so other servers don't cache them; SharedState atomically decides each user's first and last connection across workers and servers; and presence data is gathered from every worker or server and merged into one unique list. Upstream's presence_connections metric, subscription timestamps and prefix-based internal routing have no Hypervel counterpart. Two fixes are ported into the subscription's presence data: - A member without user_info made the subscription fail with an ErrorException, and a member whose user_info was {} was sent as [], because subscription data is decoded as an array. Both are now sent as {}, as upstream's merge does. - A failed presence gather propagated after the subscription was committed, so the client never got its subscription_succeeded. The failure is now reported and the subscription is answered with this worker's members. Cancellation still propagates. Tests ported: the no-user-info data test (with an empty user_info as well), the merged and unknown-channel presence cases in MetricsHandlerTest using Hypervel's snapshot payloads, the presence cache test for internal events, the findOrCreate change, and the three Redis scaling tests in RedisServerTest, with member_removed merged into the existing member notification test. EventHandlerTest covers the gather fallback and cancellation. Upstream reference: laravel/reverb main at 74c8c4082c. Validation: the changed test files, the Reverb unit and integration suites, formatting and PHPStan pass. --- .../InteractsWithPresenceChannels.php | 33 ++++++++-- tests/Integration/Reverb/RedisServerTest.php | 62 +++++++++++++++++++ .../Channels/PresenceCacheChannelTest.php | 36 ++++++++++- .../Pusher/Channels/PresenceChannelTest.php | 20 ++++++ .../Protocols/Pusher/EventHandlerTest.php | 59 ++++++++++++++++++ .../Protocols/Pusher/MetricsHandlerTest.php | 36 ++++++++--- 6 files changed, 231 insertions(+), 15 deletions(-) diff --git a/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php b/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php index 4114aec94a..4c8696c9eb 100644 --- a/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php +++ b/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php @@ -5,12 +5,16 @@ namespace Hypervel\Reverb\Protocols\Pusher\Channels\Concerns; use Hypervel\Reverb\Contracts\Connection; +use Hypervel\Reverb\FailureReporter; +use Hypervel\Reverb\Protocols\Pusher\Channels\ChannelConnection; use Hypervel\Reverb\Protocols\Pusher\EventDispatcher; use Hypervel\Reverb\Protocols\Pusher\MetricsHandler; use Hypervel\Reverb\Protocols\Pusher\MetricType; use Hypervel\Reverb\Servers\Hypervel\Contracts\SharedState; use Hypervel\Reverb\Webhooks\Contracts\WebhookDispatcher; use Hypervel\Reverb\Webhooks\DeferredWebhookManager; +use Swoole\Coroutine\CanceledException; +use Throwable; trait InteractsWithPresenceChannels { @@ -153,11 +157,28 @@ public function data(): array ]; } - $snapshot = app(MetricsHandler::class)->gather( - $connection->app(), - MetricType::Presence->value, - ['channel' => $this->name()], - ); + try { + $snapshot = app(MetricsHandler::class)->gather( + $connection->app(), + MetricType::Presence->value, + ['channel' => $this->name()], + ); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + // The subscription is already committed, so still answer it with + // this worker's members when other workers or servers can't be reached. + FailureReporter::report($exception); + + $snapshot = [ + 'users' => collect($this->connections->all()) + ->map(fn (ChannelConnection $member): array => $member->data()) + ->unique('user_id') + ->values() + ->all(), + ]; + } + $connections = collect($snapshot['users']); if ($connections->contains(fn ($connection) => ! isset($connection['user_id']))) { @@ -174,7 +195,7 @@ public function data(): array 'presence' => [ 'count' => $connections->count(), 'ids' => $connections->map(fn ($connection) => $connection['user_id'])->values()->all(), - 'hash' => $connections->keyBy('user_id')->map->user_info->toArray(), + 'hash' => $connections->pluck('user_info', 'user_id')->map(fn ($info) => $info ?: (object) [])->all(), ], ]; } diff --git a/tests/Integration/Reverb/RedisServerTest.php b/tests/Integration/Reverb/RedisServerTest.php index dc3cd9b5e5..108fc6927c 100644 --- a/tests/Integration/Reverb/RedisServerTest.php +++ b/tests/Integration/Reverb/RedisServerTest.php @@ -164,8 +164,70 @@ public function testPresenceMemberNotificationsWithRedisScaling(): void $this->assertNotNull($message, 'Client one did not receive member_added for user 2'); $this->assertSame('User 2', $this->decodeEventData($message)['user_info']['name']); + $this->disconnect($clientTwo); + + $message = $this->receiveMemberRemoved($clientOne, 2); + $this->assertNotNull($message, 'Client one did not receive member_removed for user 2'); + + $this->disconnect($clientOne); + } + + public function testIncludesExistingMembersInSubscriptionSucceededWhenScaling(): void + { + ['client' => $clientOne, 'socketId' => $socketIdOne] = $this->connect(); + $this->subscribe($clientOne, $socketIdOne, 'presence-redis-existing-members-channel', [ + 'user_id' => 1, + 'user_info' => ['name' => 'User 1'], + ]); + + ['client' => $clientTwo, 'socketId' => $socketIdTwo] = $this->connect(); + $response = $this->subscribe($clientTwo, $socketIdTwo, 'presence-redis-existing-members-channel', [ + 'user_id' => 2, + 'user_info' => ['name' => 'User 2'], + ]); + + $message = $this->decodeEventMessage($response); + $this->assertSame('pusher_internal:subscription_succeeded', $message['event']); + $this->assertSame([ + 'presence' => [ + 'count' => 2, + 'ids' => [1, 2], + 'hash' => [1 => ['name' => 'User 1'], 2 => ['name' => 'User 2']], + ], + ], $this->decodeEventData($message)); + + $this->disconnect($clientOne); + $this->disconnect($clientTwo); + } + + public function testDoesNotCacheInternalEventsOnAPresenceCacheChannelWhenScaling(): void + { + ['client' => $clientOne, 'socketId' => $socketIdOne] = $this->connect(); + $this->subscribe($clientOne, $socketIdOne, 'presence-cache-redis-internal-channel', [ + 'user_id' => 1, + 'user_info' => ['name' => 'User 1'], + ]); + + ['client' => $clientTwo, 'socketId' => $socketIdTwo] = $this->connect(); + $this->subscribe($clientTwo, $socketIdTwo, 'presence-cache-redis-internal-channel', [ + 'user_id' => 2, + 'user_info' => ['name' => 'User 2'], + ]); + + $this->assertNotNull($this->receiveMemberAdded($clientOne, 2), 'Client one did not receive member_added for user 2'); + + // A cached member event would be replayed to the next subscriber instead of a cache miss. + ['client' => $clientThree, 'socketId' => $socketIdThree] = $this->connect(); + $this->subscribe($clientThree, $socketIdThree, 'presence-cache-redis-internal-channel', [ + 'user_id' => 3, + 'user_info' => ['name' => 'User 3'], + ]); + + $this->assertNotNull($this->receiveEvent($clientThree, 'pusher:cache_miss'), 'Client three did not receive a cache miss'); + $this->disconnect($clientOne); $this->disconnect($clientTwo); + $this->disconnect($clientThree); } // ── HTTP API with Redis scaling ──────────────────────────────────── diff --git a/tests/Reverb/Protocols/Pusher/Channels/PresenceCacheChannelTest.php b/tests/Reverb/Protocols/Pusher/Channels/PresenceCacheChannelTest.php index d0bd0452fe..95f34709ca 100644 --- a/tests/Reverb/Protocols/Pusher/Channels/PresenceCacheChannelTest.php +++ b/tests/Reverb/Protocols/Pusher/Channels/PresenceCacheChannelTest.php @@ -7,6 +7,7 @@ use Hypervel\Reverb\Protocols\Pusher\Channels\ChannelConnection; use Hypervel\Reverb\Protocols\Pusher\Channels\PresenceCacheChannel; use Hypervel\Reverb\Protocols\Pusher\Contracts\ChannelConnectionManager; +use Hypervel\Reverb\Protocols\Pusher\EventDispatcher; use Hypervel\Reverb\Protocols\Pusher\Exceptions\ConnectionUnauthorized; use Hypervel\Reverb\Protocols\Pusher\Managers\ArrayChannelConnectionManager; use Hypervel\Tests\Reverb\Fixtures\FakeConnection; @@ -175,7 +176,7 @@ public function testSendsNotificationOfAnUnsubscribe(): void public function testReceivesNoDataWhenNoPreviousEventTriggered(): void { - $channel = new PresenceCacheChannel('presence-cache-test-channel'); + $channel = $this->channels()->findOrCreate('presence-cache-test-channel'); $this->channelConnectionManager->shouldReceive('add') ->once() @@ -197,4 +198,37 @@ public function testStoresLastTriggeredEvent(): void $this->assertTrue($channel->hasCachedPayload()); $this->assertEquals(['foo' => 'bar'], $channel->cachedPayload()); } + + public function testDoesNotCacheInternalEventsOnAPresenceCacheChannel(): void + { + $channel = $this->channels()->findOrCreate('presence-cache-test-channel'); + $data = json_encode(['user_info' => ['name' => 'Joe'], 'user_id' => 1]); + + $this->channelConnectionManager->shouldReceive('all')->andReturn([]); + + $channel->subscribe( + $this->connection, + static::validAuth($this->connection->id(), 'presence-cache-test-channel', $data), + $data + ); + + $this->assertFalse($channel->hasCachedPayload()); + + // Hypervel removes a vacated channel, so dispatch the remote member + // event while the channel is still registered. + EventDispatcher::dispatchInternallySynchronously($this->connection->app(), [ + 'event' => 'pusher_internal:member_added', + 'data' => json_encode(['user_id' => 2]), + 'channel' => 'presence-cache-test-channel', + ]); + + $this->assertFalse($channel->hasCachedPayload()); + + $this->channelConnectionManager->shouldReceive('find') + ->andReturn(new ChannelConnection($this->connection, ['user_info' => ['name' => 'Joe'], 'user_id' => 1])); + + $channel->unsubscribe($this->connection); + + $this->assertFalse($channel->hasCachedPayload()); + } } diff --git a/tests/Reverb/Protocols/Pusher/Channels/PresenceChannelTest.php b/tests/Reverb/Protocols/Pusher/Channels/PresenceChannelTest.php index f5882d7886..5ff89f4b4b 100644 --- a/tests/Reverb/Protocols/Pusher/Channels/PresenceChannelTest.php +++ b/tests/Reverb/Protocols/Pusher/Channels/PresenceChannelTest.php @@ -101,6 +101,26 @@ public function testCanReturnDataStoredOnTheConnection(): void ], $channel->data()); } + public function testCanReturnDataForAConnectionWithoutUserInfo(): void + { + $channel = $this->channels()->findOrCreate('presence-test-channel'); + + $connections = [ + collect(static::factory(data: ['user_id' => 1]))->first(), + // Subscription data decodes "user_info": {} as an empty array. + collect(static::factory(data: ['user_id' => 2, 'user_info' => []]))->first(), + ]; + + $this->channelConnectionManager->shouldReceive('all') + ->twice() + ->andReturn($connections); + + $this->assertSame( + '{"presence":{"count":2,"ids":[1,2],"hash":{"1":{},"2":{}}}}', + json_encode($channel->data()) + ); + } + public function testSendsNotificationOfSubscription(): void { $channel = $this->channels()->findOrCreate('presence-test-channel'); diff --git a/tests/Reverb/Protocols/Pusher/EventHandlerTest.php b/tests/Reverb/Protocols/Pusher/EventHandlerTest.php index 70d5763b28..659aa84f11 100644 --- a/tests/Reverb/Protocols/Pusher/EventHandlerTest.php +++ b/tests/Reverb/Protocols/Pusher/EventHandlerTest.php @@ -22,6 +22,7 @@ use JsonException; use Mockery as m; use RuntimeException; +use Swoole\Coroutine\CanceledException; use Swoole\Server; class EventHandlerTest extends ReverbTestCase @@ -232,6 +233,64 @@ public function testCanSubscribeToAnEmptyChannel(): void ]); } + public function testFallsBackToLocalMembersWhenTheGatherFails(): void + { + $failure = new RuntimeException('Unable to gather metrics.'); + $metrics = m::mock(MetricsHandler::class); + $metrics->expects('gather')->andThrow($failure); + $this->app->instance(MetricsHandler::class, $metrics); + + $exceptionHandler = m::mock(ExceptionHandler::class); + $exceptionHandler->expects('report')->with($failure); + $this->app->instance(ExceptionHandler::class, $exceptionHandler); + + $data = json_encode(['user_id' => 1, 'user_info' => ['name' => 'Joe']]); + + $this->pusher->handle( + $this->connection, + 'pusher:subscribe', + [ + 'channel' => 'presence-test-channel', + 'auth' => static::validAuth($this->connection->id(), 'presence-test-channel', $data), + 'channel_data' => $data, + ] + ); + + $this->connection->assertReceived([ + 'event' => 'pusher_internal:subscription_succeeded', + 'data' => json_encode(['presence' => ['count' => 1, 'ids' => [1], 'hash' => [1 => ['name' => 'Joe']]]]), + 'channel' => 'presence-test-channel', + ]); + } + + public function testGatherCancellationIsNotAnsweredWithLocalMembers(): void + { + $cancellation = new CanceledException; + $metrics = m::mock(MetricsHandler::class); + $metrics->expects('gather')->andThrow($cancellation); + $this->app->instance(MetricsHandler::class, $metrics); + + $exceptionHandler = m::mock(ExceptionHandler::class); + $exceptionHandler->shouldNotReceive('report'); + $this->app->instance(ExceptionHandler::class, $exceptionHandler); + + $data = json_encode(['user_id' => 1, 'user_info' => ['name' => 'Joe']]); + + try { + $this->pusher->subscribe( + $this->connection, + 'presence-test-channel', + static::validAuth($this->connection->id(), 'presence-test-channel', $data), + $data, + ); + $this->fail('Expected the gather cancellation to propagate.'); + } catch (CanceledException $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->connection->assertNothingReceived(); + } + public function testCanUnsubscribeFromAChannel(): void { $this->pusher->handle( diff --git a/tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php b/tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php index f2a504f555..8ecbd73efe 100644 --- a/tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php +++ b/tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php @@ -375,25 +375,45 @@ public function testScalingGatherPresenceMergesACompleteUniqueSnapshot(): void 'exists' => true, 'presence' => true, 'users' => [ - ['user_id' => 'one', 'user_info' => ['name' => 'Taylor']], - ['user_id' => 'two', 'user_info' => ['name' => 'Abigail']], + ['user_id' => 1, 'user_info' => ['name' => 'Joe']], + ['user_id' => 2, 'user_info' => ['name' => 'Jane']], ], ], [ 'exists' => true, 'presence' => true, 'users' => [ - ['user_id' => 'one', 'user_info' => ['name' => 'Taylor']], - ['user_id' => 'three', 'user_info' => ['name' => 'Nuno']], + ['user_id' => '2', 'user_info' => ['name' => 'Jane']], + ['user_id' => 3, 'user_info' => ['name' => 'Jim']], + ['user_id' => 4, 'user_info' => []], ], ], + [ + 'exists' => false, + 'presence' => false, + 'users' => [], + ], ]); - $result = $handler->gather($app, 'presence', ['channel' => 'presence-test']); + $this->assertSame([ + 'exists' => true, + 'presence' => true, + 'users' => [ + ['user_id' => 1, 'user_info' => ['name' => 'Joe']], + ['user_id' => 2, 'user_info' => ['name' => 'Jane']], + ['user_id' => 3, 'user_info' => ['name' => 'Jim']], + ['user_id' => 4, 'user_info' => []], + ], + ], $handler->gather($app, 'presence', ['channel' => 'presence-test'])); + } + + public function testScalingGatherPresenceForAChannelNoServerHas(): void + { + $app = $this->app->make(ApplicationProvider::class)->all()->first(); + $absent = ['exists' => false, 'presence' => false, 'users' => []]; + $handler = $this->scalingMetricsHandler([$absent, $absent]); - $this->assertTrue($result['exists']); - $this->assertTrue($result['presence']); - $this->assertSame(['one', 'two', 'three'], array_column($result['users'], 'user_id')); + $this->assertSame($absent, $handler->gather($app, 'presence', ['channel' => 'presence-test'])); } public function testUnscaledMultiWorkerGatherIncludesLocalAndSiblingResponses(): void From a03764244e4cc68887001aff25fc6657e63f0a08 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:59:08 +0000 Subject: [PATCH 05/13] Test the Typesense emplace import action Hypervel's Typesense engine already defaults the import action to upsert and passes the configured action to the import, as laravel/scout #955 does, but only the default was tested. The partial-engine test helper now takes config values; its config stub previously returned the default for every key. TypesenseEngineTest ports upstream's emplace action test. Upstream reference: laravel/scout 11.x at ce2542f5a7. Validation: TypesenseEngineTest, formatting and PHPStan pass. --- .../Unit/Engines/TypesenseEngineTest.php | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/Scout/Unit/Engines/TypesenseEngineTest.php b/tests/Scout/Unit/Engines/TypesenseEngineTest.php index 75b690d8aa..cf72d089ac 100644 --- a/tests/Scout/Unit/Engines/TypesenseEngineTest.php +++ b/tests/Scout/Unit/Engines/TypesenseEngineTest.php @@ -245,6 +245,27 @@ public function testUpdateImportsIntoIndexableCollection(): void $this->createPartialEngineWithConfig($client)->update(new EloquentCollection([$model])); } + public function testUpdateMethodWithEmplaceAction(): void + { + $client = m::mock(TypesenseClient::class); + $collections = m::mock(Collections::class); + $collection = m::mock(TypesenseCollection::class); + $documents = m::mock(Documents::class); + + $client->shouldReceive('getCollections')->once()->andReturn($collections); + $collections->shouldReceive('offsetGet')->with('write_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('write_index')->once(); + $collection->shouldReceive('getDocuments')->once()->andReturn($documents); + $documents->shouldReceive('import') + ->once() + ->with([['id' => 1, 'title' => 'Scout']], ['action' => 'emplace']) + ->andReturn([['success' => true, 'document' => '{"id":1}']]); + + $engine = $this->createPartialEngineWithConfig($client, config: ['typesense.import_action' => 'emplace']); + + $engine->update(new EloquentCollection([new TypesenseLifecycleModel])); + } + public function testUpdatePreparesTheFinalSearchableDocument(): void { $client = m::mock(TypesenseClient::class); @@ -1225,10 +1246,13 @@ public function testBuildSearchParametersWithEmptyQuery(): void /** * Create a partial engine mock that stubs getConfig to avoid container dependency. + * + * @param array $config */ protected function createPartialEngineWithConfig( ?MockInterface $client = null, int $maxTotalResults = 1000, + array $config = [], ): MockInterface&TypesenseEngine { $client = $client ?? m::mock(TypesenseClient::class); @@ -1238,14 +1262,7 @@ protected function createPartialEngineWithConfig( ->makePartial(); $engine->shouldReceive('getConfig') - ->andReturnUsing(function (string $key, mixed $default = null) { - // Return empty array for model-settings (no custom search params) - if (str_starts_with($key, 'typesense.model-settings.')) { - return $default; - } - - return $default; - }); + ->andReturnUsing(fn (string $key, mixed $default = null): mixed => $config[$key] ?? $default); return $engine; } From 8db4e6c66e1f3b4c46109b518aaf0c6d51b69361 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:59:18 +0000 Subject: [PATCH 06/13] Port upstream Scout job option and uniqueness tests Hypervel's Scout jobs already read their retry, backoff and exception limits from config, fail on timeout by default and support unique indexing, but some of upstream's job tests were missing. RemoveFromSearchTest ports the no-config, timeout default and timeout opt-out tests from laravel/scout #962 and #1002, replacing timeout assertions folded into the config tests. From #996 it ports the exact unique ID test, adapted to Hypervel's sha256 key, and the different-models test; the existing order test takes upstream's name. MakeSearchableTest gains the different-models test. Upstream reference: laravel/scout 11.x at ce2542f5a7. Validation: both test files, formatting and PHPStan pass. --- tests/Scout/Unit/Jobs/MakeSearchableTest.php | 8 +++ .../Scout/Unit/Jobs/RemoveFromSearchTest.php | 49 +++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/tests/Scout/Unit/Jobs/MakeSearchableTest.php b/tests/Scout/Unit/Jobs/MakeSearchableTest.php index cc3a9ca1f9..60fcd39586 100644 --- a/tests/Scout/Unit/Jobs/MakeSearchableTest.php +++ b/tests/Scout/Unit/Jobs/MakeSearchableTest.php @@ -93,6 +93,14 @@ public function testUniqueIdIsNotAffectedByModelOrder(): void ); } + public function testUniqueIdDiffersForDifferentModels(): void + { + $this->assertNotSame( + (new MakeSearchableUniquely(Collection::make([$this->model(1), $this->model(2)])))->uniqueId(), + (new MakeSearchableUniquely(Collection::make([$this->model(3), $this->model(4)])))->uniqueId() + ); + } + public function testUniqueIdDiffersForDifferentModelClasses(): void { $first = Collection::make([$this->model(1)]); diff --git a/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php b/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php index f15d0cae26..c66952ee1c 100644 --- a/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php +++ b/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php @@ -157,7 +157,15 @@ public function testJobPropertiesAreSetFromConfig(): void $this->assertSame(3, $job->tries); $this->assertSame([1, 5, 10], $job->backoff); $this->assertSame(2, $job->maxExceptions); - $this->assertTrue($job->failOnTimeout); + } + + public function testJobPropertiesAreNotSetWithoutConfig(): void + { + $job = new RemoveFromSearch(Collection::make([$this->model(1)])); + + $this->assertNull($job->tries); + $this->assertNull($job->backoff); + $this->assertNull($job->maxExceptions); } public function testSubclassJobPropertiesAreNotOverriddenByConfig(): void @@ -173,20 +181,53 @@ public function testSubclassJobPropertiesAreNotOverriddenByConfig(): void $this->assertSame(5, $job->tries); $this->assertSame([2, 4, 8, 16, 32], $job->backoff()); $this->assertSame(3, $job->maxExceptions); + } + + public function testJobFailsOnTimeoutByDefault(): void + { + $job = new RemoveFromSearch(Collection::make([$this->model(1)])); + + $this->assertTrue($job->failOnTimeout); + } + + public function testSubclassCanOptOutOfFailingOnTimeout(): void + { + $job = new OverriddenRemoveFromSearch(Collection::make([$this->model(1)])); + $this->assertFalse($job->failOnTimeout); } - public function testUniqueIdUsesSortedScoutKeys(): void + public function testUniqueIdIsBasedOnTheClassAndScoutKeys(): void { $models = Collection::make([$this->model(2), $this->model(1)]); + $expected = hash('sha256', json_encode([ + SearchableModel::class, + [1, 2], + ], JSON_THROW_ON_ERROR)); + $job = new RemoveFromSearchUniquely($models); $this->assertInstanceOf(ShouldBeUniqueUntilProcessing::class, $job); $this->assertSame(3600, $job->uniqueFor); + $this->assertSame($expected, $job->uniqueId()); + } + + public function testUniqueIdIsNotAffectedByModelOrder(): void + { + $models = Collection::make([$this->model(3), $this->model(1), $this->model(2)]); + $this->assertSame( - (new RemoveFromSearchUniquely($models->reverse()->values()))->uniqueId(), - $job->uniqueId() + (new RemoveFromSearchUniquely($models))->uniqueId(), + (new RemoveFromSearchUniquely($models->reverse()->values()))->uniqueId() + ); + } + + public function testUniqueIdDiffersForDifferentModels(): void + { + $this->assertNotSame( + (new RemoveFromSearchUniquely(Collection::make([$this->model(1), $this->model(2)])))->uniqueId(), + (new RemoveFromSearchUniquely(Collection::make([$this->model(3), $this->model(4)])))->uniqueId() ); } From 7baaef873d0fbc9082f648f44c82a47dacb5b65e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:59:27 +0000 Subject: [PATCH 07/13] Port upstream Scout where comparison tests Hypervel's Builder and engines already support where() with a comparison operator, as laravel/scout #969 added, but its per-operator tests were missing. DatabaseEngineTest ports the >, <, >=, <= and != tests, and its existing same-field comparison test takes upstream's name. The Meilisearch, Typesense and Algolia filtering integration tests ran one combined > and != query. They now run upstream's shared comparison cases, including the fixes from #976 and #978, which filter a typed numeric field and make the two-result assertions order-independent. Typesense filters its int32 ranking field, since its id is a string, and Algolia keeps its escaped-string case. Upstream reference: laravel/scout 11.x at ce2542f5a7. Validation: DatabaseEngineTest and the Meilisearch and Typesense filtering integration tests pass against those services; the Algolia integration test was not run locally because no Algolia credentials are configured. Formatting and PHPStan pass. --- .../AlgoliaFilteringIntegrationTest.php | 26 ++++----- .../MeilisearchFilteringIntegrationTest.php | 20 ++++--- .../TypesenseFilteringIntegrationTest.php | 20 ++++--- tests/Scout/Feature/DatabaseEngineTest.php | 53 ++++++++++++++++++- 4 files changed, 89 insertions(+), 30 deletions(-) diff --git a/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php b/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php index 6fe89cf472..5562fb748b 100644 --- a/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php +++ b/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php @@ -99,26 +99,26 @@ public function testWhereNotInExcludesSpecifiedValues(): void public function testComparisonFiltersAndEscapedStringValuesReachAlgolia(): void { $models = SearchableModel::withoutSyncingToSearch(fn () => new EloquentCollection([ - SearchableModel::create(['id' => 101, 'title' => 'A "quoted" \ guide', 'body' => 'Body']), - SearchableModel::create(['id' => 102, 'title' => 'Other', 'body' => 'Body']), - SearchableModel::create(['id' => 103, 'title' => 'Third', 'body' => 'Body']), + SearchableModel::create(['id' => 35, 'title' => 'Taylor Otwell', 'body' => 'Body']), + SearchableModel::create(['id' => 30, 'title' => 'A "quoted" \ guide', 'body' => 'Body']), ])); $this->engine->update($models); - $this->pollSearch($models->first()->searchableAs(), '', 3); + $this->pollSearch($models->first()->searchableAs(), '', 2); - $results = SearchableModel::search('') - ->where('id', '>', 101) - ->where('id', '!=', 103) - ->get(); + $this->assertSame([35], SearchableModel::search('')->where('id', '>', 30)->get()->pluck('id')->all()); + $this->assertEqualsCanonicalizing([35, 30], SearchableModel::search('')->where('id', '>=', 30)->get()->pluck('id')->all()); - $this->assertSame([102], $results->pluck('id')->all()); + $this->assertSame([30], SearchableModel::search('')->where('id', '<', 35)->get()->pluck('id')->all()); + $this->assertEqualsCanonicalizing([35, 30], SearchableModel::search('')->where('id', '<=', 35)->get()->pluck('id')->all()); - $results = SearchableModel::search('') - ->where('title', 'A "quoted" \ guide') - ->get(); + $this->assertSame([30], SearchableModel::search('')->where('id', '!=', 35)->get()->pluck('id')->all()); + $this->assertSame([35], SearchableModel::search('')->where('id', '!=', 30)->get()->pluck('id')->all()); + + $this->assertSame([35], SearchableModel::search('')->where('id', '>', 30)->where('id', '<', 40)->get()->pluck('id')->all()); + $this->assertSame([30], SearchableModel::search('')->where('id', '>', 25)->where('id', '<', 35)->get()->pluck('id')->all()); - $this->assertSame([101], $results->pluck('id')->all()); + $this->assertSame([30], SearchableModel::search('')->where('title', 'A "quoted" \ guide')->get()->pluck('id')->all()); } public function testBackedEnumsRetainTheirNativeFilterValues(): void diff --git a/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php index 773c50b6e5..897d69aac6 100644 --- a/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php @@ -147,19 +147,23 @@ public function testCombinedWhereAndWhereIn(): void public function testComparisonFiltersReachMeilisearch(): void { - SearchableModel::create(['id' => 301, 'title' => 'First', 'body' => 'Body']); - SearchableModel::create(['id' => 302, 'title' => 'Second', 'body' => 'Body']); - SearchableModel::create(['id' => 303, 'title' => 'Third', 'body' => 'Body']); + SearchableModel::create(['id' => 35, 'title' => 'Taylor Otwell', 'body' => 'Body']); + SearchableModel::create(['id' => 30, 'title' => 'Abigail Otwell', 'body' => 'Body']); $this->engine->update(SearchableModel::query()->get()); $this->waitForMeilisearchTasks(); - $results = SearchableModel::search('') - ->where('id', '>', 301) - ->where('id', '!=', 303) - ->get(); + $this->assertSame([35], SearchableModel::search('')->where('id', '>', 30)->get()->pluck('id')->all()); + $this->assertEqualsCanonicalizing([35, 30], SearchableModel::search('')->where('id', '>=', 30)->get()->pluck('id')->all()); + + $this->assertSame([30], SearchableModel::search('')->where('id', '<', 35)->get()->pluck('id')->all()); + $this->assertEqualsCanonicalizing([35, 30], SearchableModel::search('')->where('id', '<=', 35)->get()->pluck('id')->all()); + + $this->assertSame([30], SearchableModel::search('')->where('id', '!=', 35)->get()->pluck('id')->all()); + $this->assertSame([35], SearchableModel::search('')->where('id', '!=', 30)->get()->pluck('id')->all()); - $this->assertSame([302], $results->pluck('id')->all()); + $this->assertSame([35], SearchableModel::search('')->where('id', '>', 30)->where('id', '<', 40)->get()->pluck('id')->all()); + $this->assertSame([30], SearchableModel::search('')->where('id', '>', 25)->where('id', '<', 35)->get()->pluck('id')->all()); } public function testBackedEnumsAndEscapedSetValuesReachMeilisearch(): void diff --git a/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php b/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php index b1d03f6067..f58ea00ef0 100644 --- a/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php +++ b/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php @@ -118,18 +118,22 @@ public function testCombinedWhereAndWhereIn(): void public function testComparisonFiltersReachTypesense(): void { - TypesenseSearchableModel::create(['id' => 501, 'title' => 'First', 'body' => 'Body']); - TypesenseSearchableModel::create(['id' => 502, 'title' => 'Second', 'body' => 'Body']); - TypesenseSearchableModel::create(['id' => 503, 'title' => 'Third', 'body' => 'Body']); + TypesenseSearchableModel::create(['id' => 35, 'title' => 'Taylor Otwell', 'body' => 'Body']); + TypesenseSearchableModel::create(['id' => 30, 'title' => 'Abigail Otwell', 'body' => 'Body']); $this->engine->update(TypesenseSearchableModel::query()->get()); - $results = TypesenseSearchableModel::search('') - ->where('ranking', '>', 501) - ->where('ranking', '!=', 503) - ->get(); + $this->assertSame([35], TypesenseSearchableModel::search('')->where('ranking', '>', 30)->get()->pluck('id')->all()); + $this->assertEqualsCanonicalizing([35, 30], TypesenseSearchableModel::search('')->where('ranking', '>=', 30)->get()->pluck('id')->all()); + + $this->assertSame([30], TypesenseSearchableModel::search('')->where('ranking', '<', 35)->get()->pluck('id')->all()); + $this->assertEqualsCanonicalizing([35, 30], TypesenseSearchableModel::search('')->where('ranking', '<=', 35)->get()->pluck('id')->all()); + + $this->assertSame([30], TypesenseSearchableModel::search('')->where('ranking', '!=', 35)->get()->pluck('id')->all()); + $this->assertSame([35], TypesenseSearchableModel::search('')->where('ranking', '!=', 30)->get()->pluck('id')->all()); - $this->assertSame([502], $results->pluck('id')->all()); + $this->assertSame([35], TypesenseSearchableModel::search('')->where('ranking', '>', 30)->where('ranking', '<', 40)->get()->pluck('id')->all()); + $this->assertSame([30], TypesenseSearchableModel::search('')->where('ranking', '>', 25)->where('ranking', '<', 35)->get()->pluck('id')->all()); } public function testBackedEnumsRetainTheirNativeFilterValues(): void diff --git a/tests/Scout/Feature/DatabaseEngineTest.php b/tests/Scout/Feature/DatabaseEngineTest.php index 73d8b68393..02b7377b83 100644 --- a/tests/Scout/Feature/DatabaseEngineTest.php +++ b/tests/Scout/Feature/DatabaseEngineTest.php @@ -60,7 +60,49 @@ public function testSearchWithWhereClause(): void $this->assertEquals($model1->id, $results->first()->id); } - public function testSearchWithMultipleComparisonsOnTheSameField(): void + public function testItCanFilterWithGreaterThan(): void + { + $this->createAbigailAndTaylor(); + + $models = SearchableModel::search()->where('title', '>', 'B')->get(); + + $this->assertSame(['Taylor Otwell'], $models->pluck('title')->all()); + } + + public function testItCanFilterWithLessThan(): void + { + $this->createAbigailAndTaylor(); + + $models = SearchableModel::search()->where('title', '<', 'B')->get(); + + $this->assertSame(['Abigail Otwell'], $models->pluck('title')->all()); + } + + public function testItCanFilterWithGreaterThanOrEqual(): void + { + $this->createAbigailAndTaylor(); + + $this->assertSame(['Taylor Otwell'], SearchableModel::search()->where('title', '>=', 'T')->get()->pluck('title')->all()); + $this->assertCount(2, SearchableModel::search()->where('title', '>=', 'A')->get()); + } + + public function testItCanFilterWithLessThanOrEqual(): void + { + $this->createAbigailAndTaylor(); + + $this->assertSame(['Abigail Otwell'], SearchableModel::search()->where('title', '<=', 'Abigail Otwell')->get()->pluck('title')->all()); + $this->assertCount(2, SearchableModel::search()->where('title', '<=', 'Taylor Otwell')->get()); + } + + public function testItCanFilterWithNotEqual(): void + { + $this->createAbigailAndTaylor(); + + $this->assertSame(['Taylor Otwell'], SearchableModel::search()->where('title', '!=', 'Abigail Otwell')->get()->pluck('title')->all()); + $this->assertSame(['Abigail Otwell'], SearchableModel::search()->where('title', '!=', 'Taylor Otwell')->get()->pluck('title')->all()); + } + + public function testItCanFilterWithMultipleWhereComparisons(): void { SearchableModel::create(['title' => 'First', 'body' => 'Body']); $second = SearchableModel::create(['title' => 'Second', 'body' => 'Body']); @@ -449,6 +491,15 @@ public function testRegularModelUsesFullWildcardOnTitle(): void $this->assertCount(2, $results); } + + /** + * Create the models used by the where comparison tests. + */ + private function createAbigailAndTaylor(): void + { + SearchableModel::create(['title' => 'Taylor Otwell', 'body' => 'Body']); + SearchableModel::create(['title' => 'Abigail Otwell', 'body' => 'Body']); + } } class NamedSearchAttributesModel extends SearchableModel From f7ba618e0a2f15f7a443ead4cdf4c2a078ce5b64 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:59:46 +0000 Subject: [PATCH 08/13] Accept null Scout search queries laravel/scout #1011 tests that a collection search for null returns every model. In Hypervel, search() and the Builder constructor only accepted a string, so Model::search($request->query('q')) threw a TypeError when the request had no q parameter. Both now accept a nullable query, and the Builder stores null as an empty string so engines keep receiving a string query. The collection engine already treated only an empty string as an empty query, so searches for "0" and whitespace worked. CollectionEngineTest ports upstream's null, whitespace and paginate-for-zero tests, and the existing zero test takes upstream's name. The same file also ports #969's collection-engine >, <, >=, <= and != tests, and its existing same-field comparison test takes upstream's name. Upstream reference: laravel/scout 11.x at ce2542f5a7. Validation: CollectionEngineTest, the Scout test suite, formatting and PHPStan pass. --- src/scout/src/Builder.php | 4 +- .../src/Contracts/SearchableInterface.php | 2 +- src/scout/src/Searchable.php | 2 +- tests/Scout/Feature/CollectionEngineTest.php | 81 ++++++++++++++++++- 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/scout/src/Builder.php b/src/scout/src/Builder.php index bb119ac455..27fa256379 100644 --- a/src/scout/src/Builder.php +++ b/src/scout/src/Builder.php @@ -114,13 +114,13 @@ class Builder */ public function __construct( Model $model, - string $query, + ?string $query, ?Closure $callback = null, bool $softDelete = false ) { /** @var SearchableInterface&TModel $model */ $this->model = $model; - $this->query = $query; + $this->query = $query ?? ''; $this->callback = $callback; if ($softDelete) { diff --git a/src/scout/src/Contracts/SearchableInterface.php b/src/scout/src/Contracts/SearchableInterface.php index 2f15901a1d..43e7ec7ecb 100644 --- a/src/scout/src/Contracts/SearchableInterface.php +++ b/src/scout/src/Contracts/SearchableInterface.php @@ -27,7 +27,7 @@ interface SearchableInterface * * @return Builder */ - public static function search(string $query = '', ?Closure $callback = null): Builder; + public static function search(?string $query = '', ?Closure $callback = null): Builder; /** * Get the requested models from an array of object IDs. diff --git a/src/scout/src/Searchable.php b/src/scout/src/Searchable.php index d2d2c659a2..80d7f88f92 100644 --- a/src/scout/src/Searchable.php +++ b/src/scout/src/Searchable.php @@ -240,7 +240,7 @@ public function searchIndexShouldBeUpdated(): bool * * @return Builder */ - public static function search(string $query = '', ?Closure $callback = null): Builder + public static function search(?string $query = '', ?Closure $callback = null): Builder { // @phpstan-ignore staticProperty.notFound (models may define the documented custom builder property) $builder = static::$scoutBuilder ?? Builder::class; diff --git a/tests/Scout/Feature/CollectionEngineTest.php b/tests/Scout/Feature/CollectionEngineTest.php index c7fcf2b388..430bd3c304 100644 --- a/tests/Scout/Feature/CollectionEngineTest.php +++ b/tests/Scout/Feature/CollectionEngineTest.php @@ -46,7 +46,49 @@ public function testSearchWithWhereClause(): void $this->assertEquals($model1->id, $results->first()->id); } - public function testSearchWithMultipleComparisonsOnTheSameField(): void + public function testItCanFilterWithGreaterThan(): void + { + $this->createAbigailAndTaylor(); + + $models = SearchableModel::search()->where('title', '>', 'B')->get(); + + $this->assertSame(['Taylor Otwell'], $models->pluck('title')->all()); + } + + public function testItCanFilterWithLessThan(): void + { + $this->createAbigailAndTaylor(); + + $models = SearchableModel::search()->where('title', '<', 'B')->get(); + + $this->assertSame(['Abigail Otwell'], $models->pluck('title')->all()); + } + + public function testItCanFilterWithGreaterThanOrEqual(): void + { + $this->createAbigailAndTaylor(); + + $this->assertSame(['Taylor Otwell'], SearchableModel::search()->where('title', '>=', 'T')->get()->pluck('title')->all()); + $this->assertCount(2, SearchableModel::search()->where('title', '>=', 'A')->get()); + } + + public function testItCanFilterWithLessThanOrEqual(): void + { + $this->createAbigailAndTaylor(); + + $this->assertSame(['Abigail Otwell'], SearchableModel::search()->where('title', '<=', 'Abigail Otwell')->get()->pluck('title')->all()); + $this->assertCount(2, SearchableModel::search()->where('title', '<=', 'Taylor Otwell')->get()); + } + + public function testItCanFilterWithNotEqual(): void + { + $this->createAbigailAndTaylor(); + + $this->assertSame(['Taylor Otwell'], SearchableModel::search()->where('title', '!=', 'Abigail Otwell')->get()->pluck('title')->all()); + $this->assertSame(['Abigail Otwell'], SearchableModel::search()->where('title', '!=', 'Taylor Otwell')->get()->pluck('title')->all()); + } + + public function testItCanFilterWithMultipleWhereComparisons(): void { SearchableModel::create(['title' => 'First', 'body' => 'Body']); $second = SearchableModel::create(['title' => 'Second', 'body' => 'Body']); @@ -136,7 +178,21 @@ public function testSearchIsCaseInsensitive(): void $this->assertCount(1, $results); } - public function testSearchTreatsStringZeroAsAQuery(): void + public function testItCanRetrieveResultsWithNullSearch(): void + { + $this->createAbigailAndTaylor(); + + $this->assertCount(2, SearchableModel::search(null)->get()); + } + + public function testItSearchesForWhitespaceLiterally(): void + { + $this->createAbigailAndTaylor(); + + $this->assertCount(0, SearchableModel::search(' ')->get()); + } + + public function testItCanRetrieveResultsForZero(): void { SearchableModel::create(['title' => 'Contains 0', 'body' => 'Body']); SearchableModel::create(['title' => 'No match', 'body' => 'Body']); @@ -147,6 +203,18 @@ public function testSearchTreatsStringZeroAsAQuery(): void $this->assertSame('Contains 0', $results->first()->title); } + public function testItCanPaginateResultsForZero(): void + { + SearchableModel::create(['title' => 'Agent 0', 'body' => 'Body']); + $second = SearchableModel::create(['title' => 'Agent 00', 'body' => 'Body']); + SearchableModel::create(['title' => 'No match', 'body' => 'Body']); + + $page = SearchableModel::search('0')->orderBy('id')->paginate(1, 'page', 2); + + $this->assertSame(2, $page->total()); + $this->assertSame([$second->id], $page->getCollection()->modelKeys()); + } + public function testUpdateAndDeleteAreNoOps(): void { $model = SearchableModel::create(['title' => 'Test', 'body' => 'Body']); @@ -179,4 +247,13 @@ public function testGetTotalCountReturnsCorrectCount(): void $this->assertEquals(2, $engine->getTotalCount($results)); } + + /** + * Create the models used by the where comparison tests. + */ + private function createAbigailAndTaylor(): void + { + SearchableModel::create(['title' => 'Taylor Otwell', 'body' => 'Body']); + SearchableModel::create(['title' => 'Abigail Otwell', 'body' => 'Body']); + } } From 659b493f24f67e10a6262aa1890b8b6646d1540f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:59:53 +0000 Subject: [PATCH 09/13] Test Algolia boolean set filters Hypervel's Algolia engine already compiles boolean and inequality filters as laravel/scout #1005 does, but its test didn't cover boolean values in whereIn() and whereNotIn(). The existing boolean and inequality test now adds upstream's cases, asserting OR for inclusion and AND for exclusion with boolean literals. Upstream reference: laravel/scout 11.x at ce2542f5a7. Validation: AlgoliaEngineTest, formatting and PHPStan pass. --- tests/Scout/Unit/Engines/AlgoliaEngineTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Scout/Unit/Engines/AlgoliaEngineTest.php b/tests/Scout/Unit/Engines/AlgoliaEngineTest.php index 92c697a9ac..60757e7d01 100644 --- a/tests/Scout/Unit/Engines/AlgoliaEngineTest.php +++ b/tests/Scout/Unit/Engines/AlgoliaEngineTest.php @@ -569,7 +569,7 @@ public function testSearchSendsBooleanInequalityAndEscapedFilters(): void ->once() ->with('users', [ 'query' => 'zonda', - 'filters' => "is_live:true AND is_archived:false AND NOT status:'draft' AND NOT label:'manager\\'s draft\\\\review' AND NOT is_deleted:true AND is_featured>1 AND is_disabled<=0 AND rank>=10 AND score>=9223372036854775808 AND ratio<1.25 AND precise_ratio<1.2345678901234567", + 'filters' => "is_live:true AND is_archived:false AND NOT status:'draft' AND NOT label:'manager\\'s draft\\\\review' AND NOT is_deleted:true AND is_featured>1 AND is_disabled<=0 AND rank>=10 AND score>=9223372036854775808 AND ratio<1.25 AND precise_ratio<1.2345678901234567 AND (is_featured:true OR is_featured:false) AND NOT is_hidden:true AND NOT is_hidden:false", ], []); $engine = new AlgoliaEngine($client); @@ -588,7 +588,9 @@ public function testSearchSendsBooleanInequalityAndEscapedFilters(): void ->where('rank', '>=', 10) ->where('score', '>=', '9223372036854775808') ->where('ratio', '<', 1.25) - ->where('precise_ratio', '<', 1.2345678901234567); + ->where('precise_ratio', '<', 1.2345678901234567) + ->whereIn('is_featured', [true, false]) + ->whereNotIn('is_hidden', [true, false]); $engine->search($builder); } From c6fdad0b15a5bb6725d8adee27c6bf0339fc9b9a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:00:00 +0000 Subject: [PATCH 10/13] Correct Scout's documented differences from Laravel The Scout README listed internal changes, enhancements and fixes as differences, missed coroutine-local sync pausing, and didn't follow the package README layout. It now links the documentation and lists only the public differences that ported Laravel code has to account for: Algolia 4 only, numeric Algolia filter values, indexing without a queue, coroutine-local search-sync pausing, the tenant token method's arguments and the prefix requirement for deleting all indexes. Upstream reference: laravel/scout 11.x at ce2542f5a7. --- src/scout/README.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/scout/README.md b/src/scout/README.md index 6c1a7284b1..750ac564ce 100644 --- a/src/scout/README.md +++ b/src/scout/README.md @@ -3,16 +3,15 @@ Scout for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/scout) -Ported from: https://github.com/laravel/scout +Documentation: https://hypervel.org/docs/scout -Differences From Laravel ---- +## Differences From Laravel - Algolia 4 is the only supported Algolia client. - Numeric values passed to Algolia `where`, `whereIn`, and `whereNotIn` compile as numeric comparisons; numeric-looking strings remain facet values. -- Queue mode supports dedicated connection and queue selection; nonqueued indexing is deferred until after HTTP responses and runs immediately without an active request. -- Command imports use bounded coroutine concurrency. -- Meilisearch requests use bounded retries and sign tenant tokens from an explicit parent-key UID and secret. -- Destructive index deletion requires the configured Scout prefix. -- Boot-time lifecycle callbacks can prepare builders, documents, settings, and model flushes; external engines also support completion-aware filtered deletion. -- `Searchable::removeAllFromSearch()` accepts an optional force flag, which the explicit `scout:flush` command enables. +- Without a queue, indexing is deferred until after the HTTP response is sent, and runs immediately outside a request. +- Pausing search syncing with `withoutSyncingToSearch()` or `disableSearchSyncing()` applies only to the current coroutine, so other requests keep indexing. See [Pausing Indexing](https://hypervel.org/docs/scout#pausing-indexing). +- `MeilisearchEngine::generateTenantToken()` takes the search rules, the parent key's UID and the key itself, instead of passing the call through to the Meilisearch client, whose method takes the UID first and an options array. See [Tenant Tokens](https://hypervel.org/docs/scout#meilisearch-tenant-tokens). +- `scout:delete-all-indexes` refuses to run without a configured Scout prefix unless you pass `--force`. + +Ported from: https://github.com/laravel/scout From 550045d4e1763a4ea5e1e1d38af7617c91b3c8a1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:49:07 +0000 Subject: [PATCH 11/13] Fix whole-second timing races in limiter tests Two timing assertions occasionally failed in the Redis Cluster CI runs. The funnel lease refresh test slept 1.1 seconds after acquiring a three-second lease, then expected the refreshed lifetime to be longer than the remaining one. The database and file stores report lifetimes in whole seconds, rounding the deadline up and the current time down, so when a second ended between the two reads both values could be 3. Sleeping 2.1 seconds leaves at most 2 seconds before the refresh and at least 3 after it. The duration limiter's window runs from the current whole second to that second plus the decay, while each attempt is compared with the fractional current time. A one-second window opened late in a second only lasts for the rest of that second, so an immediate second attempt could land in a new window and succeed. This is normal fixed-window behavior, shared with Laravel's limiter, so the limiter is unchanged. The two tests that expect an immediate second attempt in a one-second window to fail now start just after a whole second. Validation: DurationLimiterIntegrationTest and the cache funnel tests pass against Redis. --- tests/Integration/Cache/CacheFunnelTestCase.php | 5 ++++- .../Redis/DurationLimiterIntegrationTest.php | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/Integration/Cache/CacheFunnelTestCase.php b/tests/Integration/Cache/CacheFunnelTestCase.php index 32025d7197..46cab9d0b3 100644 --- a/tests/Integration/Cache/CacheFunnelTestCase.php +++ b/tests/Integration/Cache/CacheFunnelTestCase.php @@ -240,7 +240,10 @@ public function testFunnelLeaseRefreshExtendsLifetime(): void $this->markTestSkipped('This cache store does not return refreshable funnel leases.'); } - usleep(1_100_000); + // Whole-second stores can report the same lifetime before and after + // the refresh when a second ends between the reads, unless at least + // two seconds have passed since acquisition. + usleep(2_100_000); $decayedLifetime = $lease->getRemainingLifetime(); $this->assertNotNull($decayedLifetime); diff --git a/tests/Integration/Redis/DurationLimiterIntegrationTest.php b/tests/Integration/Redis/DurationLimiterIntegrationTest.php index 6c2b849852..1d9fba94b8 100644 --- a/tests/Integration/Redis/DurationLimiterIntegrationTest.php +++ b/tests/Integration/Redis/DurationLimiterIntegrationTest.php @@ -56,6 +56,8 @@ public function testItFailsImmediatelyOrRetriesForAWhileBasedOnAGivenTimeout(): { $store = []; + $this->waitForNextSecond(); + (new DurationLimiter($this->redis(), 'key', 1, 1))->block(2, function () use (&$store) { $store[] = 1; }); @@ -193,6 +195,8 @@ public function testAcquireResetsAfterDecay(): void { $limiter = new DurationLimiter($this->redis(), 'reset-after-decay-key', 1, 1); + $this->waitForNextSecond(); + $this->assertTrue($limiter->acquire()); $this->assertFalse($limiter->acquire()); @@ -224,6 +228,17 @@ public function testAcquireUsesTheSelectedConnectionPrefix(): void } } + /** + * Wait until just after the next whole second. + * + * One-second windows end on a whole second, so starting just after one + * keeps an immediate second attempt inside the first window. + */ + private function waitForNextSecond(): void + { + usleep((int) ((1.05 - fmod(microtime(true), 1)) * 1_000_000)); + } + /** * Get the Redis connection for testing. */ From 4f1f7aa8042c512eaf416744c59458cd5ad20917 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:49:07 +0000 Subject: [PATCH 12/13] Assert the version header on rejected Reverb handshakes The live Reverb server test for an unsupported WebSocket version only checked the 426 status. A running Reverb server renders handshake errors through the application's exception handler, while the unit tests that check the Sec-WebSocket-Version header cover the WebSocket server's own handler. The live test now also asserts the Sec-WebSocket-Version: 13 header that RFC 6455 requires on this response, so the production path is covered. Upstream reference: laravel/reverb main at 74c8c4082c. Validation: ServerTest passes against a Reverb test server. --- tests/Integration/Reverb/ServerTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Integration/Reverb/ServerTest.php b/tests/Integration/Reverb/ServerTest.php index f012ccb24d..18ffe825ed 100644 --- a/tests/Integration/Reverb/ServerTest.php +++ b/tests/Integration/Reverb/ServerTest.php @@ -73,6 +73,7 @@ public function testRejectsAHandshakeRequestingAnUnsupportedWebsocketVersion(): $client->get('/app/' . $this->appKey); $this->assertSame(426, $client->getStatusCode()); + $this->assertSame('13', $client->getHeaders()['sec-websocket-version'] ?? null); $client->close(); } From 0c706dc43ecd20ad99864e1ded4cf49e85981f95 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:49:07 +0000 Subject: [PATCH 13/13] Correct the Scout README's tenant token entry The entry left out the search rules when describing the Meilisearch client's generateTenantToken() and didn't mention the engine's optional expiry. It now lists the arguments of both methods: Hypervel's engine takes the search rules, the parent key's UID, the key and an optional expiry, while Laravel's engine forwards the call to the client, whose method takes the UID, the search rules and an options array. Upstream reference: laravel/scout 11.x at ce2542f5a7. --- src/scout/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scout/README.md b/src/scout/README.md index 750ac564ce..1efaf15c09 100644 --- a/src/scout/README.md +++ b/src/scout/README.md @@ -11,7 +11,7 @@ Documentation: https://hypervel.org/docs/scout - Numeric values passed to Algolia `where`, `whereIn`, and `whereNotIn` compile as numeric comparisons; numeric-looking strings remain facet values. - Without a queue, indexing is deferred until after the HTTP response is sent, and runs immediately outside a request. - Pausing search syncing with `withoutSyncingToSearch()` or `disableSearchSyncing()` applies only to the current coroutine, so other requests keep indexing. See [Pausing Indexing](https://hypervel.org/docs/scout#pausing-indexing). -- `MeilisearchEngine::generateTenantToken()` takes the search rules, the parent key's UID and the key itself, instead of passing the call through to the Meilisearch client, whose method takes the UID first and an options array. See [Tenant Tokens](https://hypervel.org/docs/scout#meilisearch-tenant-tokens). +- `MeilisearchEngine::generateTenantToken()` takes the search rules, the parent key's UID, the key itself and an optional expiry. Laravel's engine forwards the call to the Meilisearch client, whose method takes the UID, the search rules and an options array. See [Tenant Tokens](https://hypervel.org/docs/scout#meilisearch-tenant-tokens). - `scout:delete-all-indexes` refuses to run without a configured Scout prefix unless you pass `--force`. Ported from: https://github.com/laravel/scout