diff --git a/composer.json b/composer.json index 5425693c18..276de5f921 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "cboden/ratchet": "^0.4.1", "clue/buzz-react": "^2.5", "clue/redis-react": "^2.3", + "evenement/evenement": "^2.0|^3.0", "facade/ignition-contracts": "^1.0", "guzzlehttp/psr7": "^1.5", "illuminate/broadcasting": "^6.0|^7.0", diff --git a/tests/Channels/ChannelReplicationTest.php b/tests/Channels/ChannelReplicationTest.php index 364e74d545..4480442742 100644 --- a/tests/Channels/ChannelReplicationTest.php +++ b/tests/Channels/ChannelReplicationTest.php @@ -2,6 +2,7 @@ namespace BeyondCode\LaravelWebSockets\Tests\Channels; +use BeyondCode\LaravelWebSockets\Tests\Mocks\Message; use BeyondCode\LaravelWebSockets\Tests\TestCase; class ChannelReplicationTest extends TestCase @@ -16,10 +17,142 @@ public function setUp(): void $this->runOnlyOnRedisReplication(); } - public function test_not_implemented() + /** @test */ + public function replication_clients_can_subscribe_to_channels() { - $this->markTestIncomplete( - 'Not yet implemented tests.' - ); + $connection = $this->getWebSocketConnection(); + + $message = new Message(json_encode([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'channel' => 'basic-channel', + ], + ])); + + $this->pusherServer->onOpen($connection); + + $this->pusherServer->onMessage($connection, $message); + + $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ + 'channel' => 'basic-channel', + ]); + } + + /** @test */ + public function replication_clients_can_unsubscribe_from_channels() + { + $connection = $this->getConnectedWebSocketConnection(['test-channel']); + + $channel = $this->getChannel($connection, 'test-channel'); + + $this->assertTrue($channel->hasConnections()); + + $message = new Message(json_encode([ + 'event' => 'pusher:unsubscribe', + 'data' => [ + 'channel' => 'test-channel', + ], + ])); + + $this->pusherServer->onMessage($connection, $message); + + $this->assertFalse($channel->hasConnections()); + } + + /** @test */ + public function replication_a_client_cannot_broadcast_to_other_clients_by_default() + { + // One connection inside channel "test-channel". + $existingConnection = $this->getConnectedWebSocketConnection(['test-channel']); + + $connection = $this->getConnectedWebSocketConnection(['test-channel']); + + $message = new Message('{"event": "client-test", "data": {}, "channel": "test-channel"}'); + + $this->pusherServer->onMessage($connection, $message); + + $existingConnection->assertNotSentEvent('client-test'); + } + + /** @test */ + public function replication_a_client_can_be_enabled_to_broadcast_to_other_clients() + { + config()->set('websockets.apps.0.enable_client_messages', true); + + // One connection inside channel "test-channel". + $existingConnection = $this->getConnectedWebSocketConnection(['test-channel']); + + $connection = $this->getConnectedWebSocketConnection(['test-channel']); + + $message = new Message('{"event": "client-test", "data": {}, "channel": "test-channel"}'); + + $this->pusherServer->onMessage($connection, $message); + + $existingConnection->assertSentEvent('client-test'); + } + + /** @test */ + public function replication_closed_connections_get_removed_from_all_connected_channels() + { + $connection = $this->getConnectedWebSocketConnection(['test-channel-1', 'test-channel-2']); + + $channel1 = $this->getChannel($connection, 'test-channel-1'); + $channel2 = $this->getChannel($connection, 'test-channel-2'); + + $this->assertTrue($channel1->hasConnections()); + $this->assertTrue($channel2->hasConnections()); + + $this->pusherServer->onClose($connection); + + $this->assertFalse($channel1->hasConnections()); + $this->assertFalse($channel2->hasConnections()); + } + + /** @test */ + public function replication_channels_can_broadcast_messages_to_all_connections() + { + $connection1 = $this->getConnectedWebSocketConnection(['test-channel']); + $connection2 = $this->getConnectedWebSocketConnection(['test-channel']); + + $channel = $this->getChannel($connection1, 'test-channel'); + + $channel->broadcast([ + 'event' => 'broadcasted-event', + 'channel' => 'test-channel', + ]); + + $connection1->assertSentEvent('broadcasted-event'); + $connection2->assertSentEvent('broadcasted-event'); + } + + /** @test */ + public function replication_channels_can_broadcast_messages_to_all_connections_except_the_given_connection() + { + $connection1 = $this->getConnectedWebSocketConnection(['test-channel']); + $connection2 = $this->getConnectedWebSocketConnection(['test-channel']); + + $channel = $this->getChannel($connection1, 'test-channel'); + + $channel->broadcastToOthers($connection1, (object) [ + 'event' => 'broadcasted-event', + 'channel' => 'test-channel', + ]); + + $connection1->assertNotSentEvent('broadcasted-event'); + $connection2->assertSentEvent('broadcasted-event'); + } + + /** @test */ + public function replication_it_responds_correctly_to_the_ping_message() + { + $connection = $this->getConnectedWebSocketConnection(); + + $message = new Message(json_encode([ + 'event' => 'pusher:ping', + ])); + + $this->pusherServer->onMessage($connection, $message); + + $connection->assertSentEvent('pusher:pong'); } } diff --git a/tests/Channels/PresenceChannelReplicationTest.php b/tests/Channels/PresenceChannelReplicationTest.php index 0d605f7f70..822ef4e1f1 100644 --- a/tests/Channels/PresenceChannelReplicationTest.php +++ b/tests/Channels/PresenceChannelReplicationTest.php @@ -50,20 +50,87 @@ public function clients_with_valid_auth_signatures_can_join_presence_channels() $connection->socketId, json_encode($channelData), ]) - ->assertCalledWithArgs('hgetall', [ - '1234:presence-channel', - ]); - // TODO: This fails somehow - // Debugging shows the exact same pattern as good. - /* ->assertCalledWithArgs('publish', [ - '1234:presence-channel', - json_encode([ - 'event' => 'pusher_internal:member_added', - 'channel' => 'presence-channel', - 'data' => $channelData, - 'appId' => '1234', - 'serverId' => $this->app->make(ReplicationInterface::class)->getServerId(), - ]), - ]) */ + ->assertCalledWithArgs('hgetall', ['1234:presence-channel']) + ->assertCalled('publish'); + } + + /** @test */ + public function clients_with_valid_auth_signatures_can_leave_presence_channels() + { + $connection = $this->getWebSocketConnection(); + + $this->pusherServer->onOpen($connection); + + $channelData = [ + 'user_id' => 1, + ]; + + $signature = "{$connection->socketId}:presence-channel:".json_encode($channelData); + + $message = new Message(json_encode([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), + 'channel' => 'presence-channel', + 'channel_data' => json_encode($channelData), + ], + ])); + + $this->pusherServer->onMessage($connection, $message); + + $this->getSubscribeClient() + ->assertEventDispatched('message'); + + $this->getPublishClient() + ->assertCalled('hset') + ->assertCalledWithArgs('hgetall', ['1234:presence-channel']) + ->assertCalled('publish'); + + $this->getPublishClient() + ->resetAssertions(); + + $message = new Message(json_encode([ + 'event' => 'pusher:unsubscribe', + 'data' => [ + 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), + 'channel' => 'presence-channel', + ], + ])); + + $this->pusherServer->onMessage($connection, $message); + + $this->getPublishClient() + ->assertCalled('hdel') + ->assertCalled('publish'); + } + + /** @test */ + public function clients_with_no_user_info_can_join_presence_channels() + { + $connection = $this->getWebSocketConnection(); + + $this->pusherServer->onOpen($connection); + + $channelData = [ + 'user_id' => 1, + ]; + + $signature = "{$connection->socketId}:presence-channel:".json_encode($channelData); + + $message = new Message(json_encode([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), + 'channel' => 'presence-channel', + 'channel_data' => json_encode($channelData), + ], + ])); + + $this->pusherServer->onMessage($connection, $message); + + $this->getPublishClient() + ->assertCalled('hset') + ->assertcalledWithArgs('hgetall', ['1234:presence-channel']) + ->assertCalled('publish'); } } diff --git a/tests/Channels/PrivateChannelReplicationTest.php b/tests/Channels/PrivateChannelReplicationTest.php index bbc768ca97..cc4bab725a 100644 --- a/tests/Channels/PrivateChannelReplicationTest.php +++ b/tests/Channels/PrivateChannelReplicationTest.php @@ -2,7 +2,9 @@ namespace BeyondCode\LaravelWebSockets\Tests\Channels; +use BeyondCode\LaravelWebSockets\Tests\Mocks\Message; use BeyondCode\LaravelWebSockets\Tests\TestCase; +use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\InvalidSignature; class PrivateChannelReplicationTest extends TestCase { @@ -16,10 +18,49 @@ public function setUp(): void $this->runOnlyOnRedisReplication(); } - public function test_not_implemented() + /** @test */ + public function replication_clients_need_valid_auth_signatures_to_join_private_channels() { - $this->markTestIncomplete( - 'Not yet implemented tests.' - ); + $this->expectException(InvalidSignature::class); + + $connection = $this->getWebSocketConnection(); + + $message = new Message(json_encode([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'auth' => 'invalid', + 'channel' => 'private-channel', + ], + ])); + + $this->pusherServer->onOpen($connection); + + $this->pusherServer->onMessage($connection, $message); + } + + /** @test */ + public function replication_clients_with_valid_auth_signatures_can_join_private_channels() + { + $connection = $this->getWebSocketConnection(); + + $this->pusherServer->onOpen($connection); + + $signature = "{$connection->socketId}:private-channel"; + + $hashedAppSecret = hash_hmac('sha256', $signature, $connection->app->secret); + + $message = new Message(json_encode([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'auth' => "{$connection->app->key}:{$hashedAppSecret}", + 'channel' => 'private-channel', + ], + ])); + + $this->pusherServer->onMessage($connection, $message); + + $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ + 'channel' => 'private-channel', + ]); } } diff --git a/tests/HttpApi/FetchChannelReplicationTest.php b/tests/HttpApi/FetchChannelReplicationTest.php index 92d265b73c..3d36f916a3 100644 --- a/tests/HttpApi/FetchChannelReplicationTest.php +++ b/tests/HttpApi/FetchChannelReplicationTest.php @@ -103,7 +103,8 @@ public function replication_it_returns_presence_channel_information() /** @var JsonResponse $response */ $response = array_pop($connection->sentRawData); - $this->getSubscribeClient()->assertNothingCalled(); + $this->getSubscribeClient() + ->assertEventDispatched('message'); $this->getPublishClient() ->assertCalled('hset') diff --git a/tests/HttpApi/FetchChannelsReplicationTest.php b/tests/HttpApi/FetchChannelsReplicationTest.php index 8dd09d69f9..ac87a62b1a 100644 --- a/tests/HttpApi/FetchChannelsReplicationTest.php +++ b/tests/HttpApi/FetchChannelsReplicationTest.php @@ -2,7 +2,12 @@ namespace BeyondCode\LaravelWebSockets\Tests\HttpApi; +use BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchChannelsController; +use BeyondCode\LaravelWebSockets\Tests\Mocks\Connection; use BeyondCode\LaravelWebSockets\Tests\TestCase; +use GuzzleHttp\Psr7\Request; +use Illuminate\Http\JsonResponse; +use Pusher\Pusher; class FetchChannelsReplicationTest extends TestCase { @@ -16,10 +21,160 @@ public function setUp(): void $this->runOnlyOnRedisReplication(); } - public function test_not_implemented() + /** @test */ + public function replication_it_returns_the_channel_information() { - $this->markTestIncomplete( - 'Not yet implemented tests.' - ); + $this->joinPresenceChannel('presence-channel'); + + $connection = new Connection(); + + $requestPath = '/apps/1234/channels'; + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(FetchChannelsController::class); + + $controller->onOpen($connection, $request); + + /** @var JsonResponse $response */ + $response = array_pop($connection->sentRawData); + + $this->getSubscribeClient() + ->assertEventDispatched('message'); + + $this->getPublishClient() + ->assertCalled('hset') + ->assertCalledWithArgs('hgetall', ['1234:presence-channel']) + ->assertCalled('publish') + ->assertCalled('multi') + ->assertCalledWithArgs('hlen', ['1234:presence-channel']) + ->assertCalled('exec'); + } + + /** @test */ + public function replication_it_returns_the_channel_information_for_prefix() + { + $this->joinPresenceChannel('presence-global.1'); + $this->joinPresenceChannel('presence-global.1'); + $this->joinPresenceChannel('presence-global.2'); + $this->joinPresenceChannel('presence-notglobal.2'); + + $connection = new Connection(); + + $requestPath = '/apps/1234/channels'; + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath, [ + 'filter_by_prefix' => 'presence-global', + ]); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(FetchChannelsController::class); + + $controller->onOpen($connection, $request); + + /** @var JsonResponse $response */ + $response = array_pop($connection->sentRawData); + + $this->getSubscribeClient() + ->assertEventDispatched('message'); + + $this->getPublishClient() + ->assertCalled('hset') + ->assertCalledWithArgs('hgetall', ['1234:presence-global.1']) + ->assertCalledWithArgs('hgetall', ['1234:presence-global.2']) + ->assertCalledWithArgs('hgetall', ['1234:presence-notglobal.2']) + ->assertCalled('publish') + ->assertCalled('multi') + ->assertCalledWithArgs('hlen', ['1234:presence-global.1']) + ->assertCalledWithArgs('hlen', ['1234:presence-global.2']) + ->assertNotCalledWithArgs('hlen', ['1234:presence-notglobal.2']) + ->assertCalled('exec'); + } + + /** @test */ + public function replication_it_returns_the_channel_information_for_prefix_with_user_count() + { + $this->joinPresenceChannel('presence-global.1'); + $this->joinPresenceChannel('presence-global.1'); + $this->joinPresenceChannel('presence-global.2'); + $this->joinPresenceChannel('presence-notglobal.2'); + + $connection = new Connection(); + + $requestPath = '/apps/1234/channels'; + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath, [ + 'filter_by_prefix' => 'presence-global', + 'info' => 'user_count', + ]); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(FetchChannelsController::class); + + $controller->onOpen($connection, $request); + + /** @var JsonResponse $response */ + $response = array_pop($connection->sentRawData); + + $this->getSubscribeClient() + ->assertEventDispatched('message'); + + $this->getPublishClient() + ->assertCalled('hset') + ->assertCalledWithArgs('hgetall', ['1234:presence-global.1']) + ->assertCalledWithArgs('hgetall', ['1234:presence-global.2']) + ->assertCalledWithArgs('hgetall', ['1234:presence-notglobal.2']) + ->assertCalled('publish') + ->assertCalled('multi') + ->assertCalledWithArgs('hlen', ['1234:presence-global.1']) + ->assertCalledWithArgs('hlen', ['1234:presence-global.2']) + ->assertNotCalledWithArgs('hlen', ['1234:presence-notglobal.2']) + ->assertCalled('exec'); + } + + /** @test */ + public function replication_it_returns_empty_object_for_no_channels_found() + { + $connection = new Connection(); + + $requestPath = '/apps/1234/channels'; + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(FetchChannelsController::class); + + $controller->onOpen($connection, $request); + + /** @var JsonResponse $response */ + $response = array_pop($connection->sentRawData); + + $this->getSubscribeClient() + ->assertEventDispatched('message'); + + $this->getPublishClient() + ->assertNotCalled('hset') + ->assertNotCalled('hgetall') + ->assertNotCalled('publish') + ->assertCalled('multi') + ->assertNotCalled('hlen') + ->assertCalled('exec'); } } diff --git a/tests/HttpApi/FetchUsersReplicationTest.php b/tests/HttpApi/FetchUsersReplicationTest.php index def2b47af5..39d79c34ae 100644 --- a/tests/HttpApi/FetchUsersReplicationTest.php +++ b/tests/HttpApi/FetchUsersReplicationTest.php @@ -2,7 +2,12 @@ namespace BeyondCode\LaravelWebSockets\Tests\HttpApi; +use BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchUsersController; +use BeyondCode\LaravelWebSockets\Tests\Mocks\Connection; use BeyondCode\LaravelWebSockets\Tests\TestCase; +use GuzzleHttp\Psr7\Request; +use Pusher\Pusher; +use Symfony\Component\HttpKernel\Exception\HttpException; class FetchUsersReplicationTest extends TestCase { @@ -16,10 +21,111 @@ public function setUp(): void $this->runOnlyOnRedisReplication(); } - public function test_not_implemented() + /** @test */ + public function test_invalid_signatures_can_not_access_the_api() { - $this->markTestIncomplete( - 'Not yet implemented tests.' - ); + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Invalid auth signature provided.'); + + $connection = new Connection(); + + $requestPath = '/apps/1234/channel/my-channel'; + $routeParams = [ + 'appId' => '1234', + 'channelName' => 'my-channel', + ]; + + $queryString = Pusher::build_auth_query_string('TestKey', 'InvalidSecret', 'GET', $requestPath); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(FetchUsersController::class); + + $controller->onOpen($connection, $request); + } + + /** @test */ + public function test_it_only_returns_data_for_presence_channels() + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Invalid presence channel'); + + $this->getConnectedWebSocketConnection(['my-channel']); + + $connection = new Connection(); + + $requestPath = '/apps/1234/channel/my-channel/users'; + $routeParams = [ + 'appId' => '1234', + 'channelName' => 'my-channel', + ]; + + $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(FetchUsersController::class); + + $controller->onOpen($connection, $request); + } + + /** @test */ + public function test_it_returns_404_for_invalid_channels() + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unknown channel'); + + $this->getConnectedWebSocketConnection(['my-channel']); + + $connection = new Connection(); + + $requestPath = '/apps/1234/channel/invalid-channel/users'; + $routeParams = [ + 'appId' => '1234', + 'channelName' => 'invalid-channel', + ]; + + $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(FetchUsersController::class); + + $controller->onOpen($connection, $request); + } + + /** @test */ + public function test_it_returns_connected_user_information() + { + $this->skipOnRedisReplication(); + + $this->joinPresenceChannel('presence-channel'); + + $connection = new Connection(); + + $requestPath = '/apps/1234/channel/presence-channel/users'; + $routeParams = [ + 'appId' => '1234', + 'channelName' => 'presence-channel', + ]; + + $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(FetchUsersController::class); + + $controller->onOpen($connection, $request); + + /** @var \Illuminate\Http\JsonResponse $response */ + $response = array_pop($connection->sentRawData); + + $this->assertSame([ + 'users' => [ + [ + 'id' => 1, + ], + ], + ], json_decode($response->getContent(), true)); } } diff --git a/tests/Mocks/LazyClient.php b/tests/Mocks/LazyClient.php index b38c23ae91..ab3e224854 100644 --- a/tests/Mocks/LazyClient.php +++ b/tests/Mocks/LazyClient.php @@ -14,6 +14,13 @@ class LazyClient extends BaseLazyClient */ protected $calls = []; + /** + * A list of called events for the connector. + * + * @var array + */ + protected $events = []; + /** * {@inheritdoc} */ @@ -24,6 +31,16 @@ public function __call($name, $args) return parent::__call($name, $args); } + /** + * {@inheritdoc} + */ + public function on($event, callable $listener) + { + $this->events[] = $event; + + return parent::on($event, $listener); + } + /** * Check if the method got called. * @@ -71,6 +88,53 @@ public function assertCalledWithArgs($name, array $args) return $this; } + /** + * Check if the method didn't call. + * + * @param string $name + * @return $this + */ + public function assertNotCalled($name) + { + foreach ($this->getCalledFunctions() as $function) { + [$calledName, ] = $function; + + if ($calledName === $name) { + PHPUnit::assertFalse(true); + + return $this; + } + } + + PHPUnit::assertTrue(true); + + return $this; + } + + /** + * Check if the method got not called with specific args. + * + * @param string $name + * @param array $args + * @return $this + */ + public function assertNotCalledWithArgs($name, array $args) + { + foreach ($this->getCalledFunctions() as $function) { + [$calledName, $calledArgs] = $function; + + if ($calledName === $name && $calledArgs === $args) { + PHPUnit::assertFalse(true); + + return $this; + } + } + + PHPUnit::assertTrue(true); + + return $this; + } + /** * Check if no function got called. * @@ -83,6 +147,39 @@ public function assertNothingCalled() return $this; } + /** + * Check if the event got dispatched. + * + * @param string $event + * @return $this + */ + public function assertEventDispatched($event) + { + foreach ($this->getCalledEvents() as $dispatchedEvent) { + if ($dispatchedEvent === $event) { + PHPUnit::assertTrue(true); + + return $this; + } + } + + PHPUnit::assertFalse(true); + + return $this; + } + + /** + * Check if no function got called. + * + * @return $this + */ + public function assertNothingDispatched() + { + PHPUnit::assertEquals([], $this->getCalledEvents()); + + return $this; + } + /** * Get the list of all calls. * @@ -92,4 +189,40 @@ public function getCalledFunctions() { return $this->calls; } + + /** + * Get the list of events. + * + * @return array + */ + public function getCalledEvents() + { + return $this->events; + } + + /** + * Dump the assertions. + * + * @return void + */ + public function dd() + { + dd([ + 'functions' => $this->getCalledFunctions(), + 'events' => $this->getCalledEvents(), + ]); + } + + /** + * Reset the assertions. + * + * @return $this + */ + public function resetAssertions() + { + $this->calls = []; + $this->events = []; + + return $this; + } }