diff --git a/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php b/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php index 4114aec94..4c8696c9e 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/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php b/src/reverb/src/Protocols/Pusher/Http/Controllers/Controller.php index 8d5510d7c..f3cb818cd 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/src/scout/README.md b/src/scout/README.md index 6c1a7284b..1efaf15c0 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, 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 diff --git a/src/scout/src/Builder.php b/src/scout/src/Builder.php index bb119ac45..27fa25637 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 2f15901a1..43e7ec7ec 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 d2d2c659a..80d7f88f9 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/src/websocket-server/src/Security.php b/src/websocket-server/src/Security.php index 798f40397..f0863e047 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 8fd6530e5..92ac47ee5 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/Cache/CacheFunnelTestCase.php b/tests/Integration/Cache/CacheFunnelTestCase.php index 32025d719..46cab9d0b 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 6c2b84985..1d9fba94b 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. */ diff --git a/tests/Integration/Reverb/RedisServerTest.php b/tests/Integration/Reverb/RedisServerTest.php index dc3cd9b5e..108fc6927 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/Integration/Reverb/ServerTest.php b/tests/Integration/Reverb/ServerTest.php index 3baf7710f..18ffe825e 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,41 @@ 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()); + $this->assertSame('13', $client->getHeaders()['sec-websocket-version'] ?? null); + + $client->close(); + } + // ── Channel subscriptions ────────────────────────────────────────── public function testCanSubscribeToAPublicChannel(): void diff --git a/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php b/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php index 6fe89cf47..5562fb748 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 773c50b6e..897d69aac 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 b1d03f606..f58ea00ef 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/Reverb/Protocols/Pusher/Channels/PresenceCacheChannelTest.php b/tests/Reverb/Protocols/Pusher/Channels/PresenceCacheChannelTest.php index d0bd0452f..95f34709c 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 f5882d788..5ff89f4b4 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/ClientEventTest.php b/tests/Reverb/Protocols/Pusher/ClientEventTest.php index d32293f3e..64cb622f6 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, diff --git a/tests/Reverb/Protocols/Pusher/EventHandlerTest.php b/tests/Reverb/Protocols/Pusher/EventHandlerTest.php index 70d5763b2..659aa84f1 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/Http/Controllers/ChannelsControllerTest.php b/tests/Reverb/Protocols/Pusher/Http/Controllers/ChannelsControllerTest.php index 226935e43..eb1e0431e 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/Protocols/Pusher/MetricsHandlerTest.php b/tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php index f2a504f55..8ecbd73ef 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 diff --git a/tests/Reverb/ReverbTestCase.php b/tests/Reverb/ReverbTestCase.php index 59a27aa36..74a468058 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, '?') : ''; diff --git a/tests/Scout/Feature/CollectionEngineTest.php b/tests/Scout/Feature/CollectionEngineTest.php index c7fcf2b38..430bd3c30 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']); + } } diff --git a/tests/Scout/Feature/DatabaseEngineTest.php b/tests/Scout/Feature/DatabaseEngineTest.php index 73d8b6839..02b7377b8 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 diff --git a/tests/Scout/Unit/Engines/AlgoliaEngineTest.php b/tests/Scout/Unit/Engines/AlgoliaEngineTest.php index 92c697a9a..60757e7d0 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); } diff --git a/tests/Scout/Unit/Engines/TypesenseEngineTest.php b/tests/Scout/Unit/Engines/TypesenseEngineTest.php index 75b690d8a..cf72d089a 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; } diff --git a/tests/Scout/Unit/Jobs/MakeSearchableTest.php b/tests/Scout/Unit/Jobs/MakeSearchableTest.php index cc3a9ca1f..60fcd3958 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 f15d0cae2..c66952ee1 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() ); } diff --git a/tests/Sentry/WebSocketRuntimeContextTest.php b/tests/Sentry/WebSocketRuntimeContextTest.php index eabe2dcea..246c64fb0 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 d5decca08..54c6a8dda 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 = [];